nodes/player.py

Part of Mr. Rescue.

  1"""Firefighter player: state machine + water gun raycast.
  2
  3Renders via Sprite2D children (run frames + carry frames + climb frames).
  4Active animation drives the visible texture each frame.
  5
  6The state machine keeps to the 5 upstream states. The water stream is resolved
  7in tile space against the TileGrid (walls stop it, fires take damage), then
  8swept as a single AABB against the enemies owned by the gameplay scene.
  9"""
 10
 11from __future__ import annotations
 12
 13from simvx.core import Input, Node2D, Signal, Sprite2D, Vec2
 14
 15from . import colours as C
 16from . import textures
 17from .tile_grid import TILE_SIZE, TileGrid
 18
 19# State enum
 20PS_RUN = 0
 21PS_CLIMB = 1
 22PS_CARRY = 2
 23PS_THROW = 3
 24PS_DEAD = 4
 25
 26# Gun direction
 27GD_UP = 0
 28GD_HORIZONTAL = 2
 29GD_DOWN = 4
 30
 31# Tunables (faithful to upstream's player.lua)
 32RUN_ACCEL = 500.0
 33MAX_SPEED = 160.0
 34MAX_SPEED_CARRY = 100.0
 35BRAKE_SPEED = 250.0
 36GRAVITY = 350.0
 37JUMP_POWER = 135.0
 38CLIMB_SPEED = 60.0
 39COYOTE_TIME = 0.08  # grace window to still jump just after leaving an edge
 40JUMP_BUFFER = 0.12  # remember a jump press made just before landing
 41STREAM_SPEED = 400.0
 42MAX_STREAM = 100.0
 43USE_RATE = 2.5
 44BURN_DAMAGE = 0.5
 45TIME_DAMAGE = 0.008
 46
 47PLAYER_W = 12  # AABB half-extents from upstream "Player.corners"
 48PLAYER_H = 22
 49
 50
 51class Player(Node2D):
 52    """Firefighter: handles its own physics, gun, and animation choice.
 53
 54    Parent gameplay scene calls ``set_world(...)`` after construction so the
 55    player can query civilians/enemies/fires for stream collision, then
 56    connects to the signals below for juice, audio and scoring.
 57    """
 58
 59    water_hit = Signal(Vec2, str)  # world position, "hit" | "extinguish"
 60    sprayed = Signal(Vec2, Vec2)  # muzzle position, beam direction
 61    wall_splashed = Signal(Vec2, Vec2)  # impact position, beam direction
 62    jumped = Signal()
 63    climbed = Signal()
 64    tank_emptied = Signal()  # trigger pulled on an overloaded tank
 65    died = Signal()
 66
 67    def __init__(self, *, position: Vec2, level: int = 1, **kwargs):
 68        super().__init__(position=position, **kwargs)
 69
 70        self.xspeed = 0.0
 71        self.yspeed = 0.0
 72        self.on_ground = False
 73        self.dir = 1  # 1 = right, -1 = left
 74        self.last_dir = 1
 75        self.state = PS_RUN
 76
 77        self.gundir = GD_HORIZONTAL
 78        self.shooting = False
 79        self.stream_length = 0.0
 80        self.stream_collided = False
 81
 82        # Tank
 83        self.regen_rate = 3.0
 84        self.water_capacity = 5.0
 85        self.water = self.water_capacity
 86        self.overloaded = False
 87
 88        # Heat
 89        self.temperature = 0.0
 90        if level == 1:
 91            self.max_temperature = 1.5
 92        elif level == 2:
 93            self.max_temperature = 1.2
 94        else:
 95            self.max_temperature = 1.0
 96        self.heat = 0.0
 97
 98        # Carry
 99        self.can_grab = False
100        self.grabbed = None  # Civilian | None
101        self._throw_time = 0.0
102        # State to drop back into when we leave a ladder: mounting while carrying
103        # must not quietly turn a carry into an empty-handed run.
104        self._climb_return = PS_RUN
105
106        # Input feel: coyote time + jump buffering (modern platformer forgiveness).
107        self._coyote = 0.0
108        self._jump_buffer = 0.0
109
110        # Animation
111        self._frames_run = textures.get("player_run")
112        self._frames_carry = textures.get("player_carry")
113        self._frames_climb = textures.get("player_climb")
114        self._frame_dead = textures.get("player_dead")
115        self._anim_t = 0.0
116        self._water_frame = 0.0
117
118        # Sprite: single Sprite2D, swap textures by anim state.
119        self._sprite = Sprite2D(
120            texture=self._frames_run[0],
121            width=16,
122            height=22,
123            filter="nearest",
124        )
125        self.add_child(self._sprite)
126
127        # World refs (filled via set_world).
128        self._grid: TileGrid | None = None
129        self._civilians: list = []
130        self._enemies: list = []
131        self._fires = None
132
133    # ----------------------------------------------------------- world wiring
134
135    def set_world(self, *, grid, civilians, enemies, fires):
136        self._grid = grid
137        self._civilians = civilians
138        self._enemies = enemies
139        self._fires = fires
140
141    # ----------------------------------------------------------- queries
142
143    @property
144    def is_dying(self) -> bool:
145        return self.temperature > self.max_temperature * 0.75
146
147    def aabb(self) -> tuple[float, float, float, float]:
148        # Top-left corner + size.
149        return (
150            self.position.x - PLAYER_W / 2,
151            self.position.y - PLAYER_H,
152            PLAYER_W,
153            PLAYER_H,
154        )
155
156    def collides_box(self, box: tuple[float, float, float, float]) -> bool:
157        ax, ay, aw, ah = self.aabb()
158        bx, by, bw, bh = box
159        return not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay)
160
161    # ----------------------------------------------------------- update
162
163    def on_update(self, dt: float):
164        if dt <= 0:
165            return
166        if self.state == PS_DEAD:
167            self._update_dead(dt)
168            return
169        # Reset shooting flag: set if shoot key held this frame.
170        self.shooting = False
171
172        # ---- input dispatch (the player owns all of it; see on_update note) ----
173        self._coyote = max(0.0, self._coyote - dt)
174        self._jump_buffer = max(0.0, self._jump_buffer - dt)
175        if Input.is_action_just_pressed("jump"):
176            self.request_jump()
177        if Input.is_action_just_pressed("grab"):
178            self.action("grab")
179        # Mount a ladder by HOLDING up/down over it (level-triggered, not a
180        # one-frame edge), but never while spraying -- so "shoot + up" aims the
181        # stream at the ceiling and "up" alone on a ladder climbs. Matches the
182        # upstream gating (player.lua only climbs when shooting == false).
183        # Carrying a civilian is no bar: the rescue is carrying her up and out.
184        if (
185            self.state in (PS_RUN, PS_CARRY)
186            and not Input.is_action_pressed("shoot")
187            and (Input.is_action_pressed("up") or Input.is_action_pressed("down"))
188            and self._on_ladder()
189        ):
190            self._enter_climb()
191
192        if self.state == PS_RUN:
193            self._update_running(dt)
194            self._consume_jump()
195            self._update_gun(dt)
196        elif self.state == PS_CLIMB:
197            self._update_climbing(dt)
198        elif self.state == PS_CARRY:
199            self._update_running(dt)  # carrying still moves
200            self._consume_jump()
201        elif self.state == PS_THROW:
202            self._update_running(dt)
203            self._consume_jump()
204            self._throw_time -= dt
205            if self._throw_time <= 0:
206                self._set_state(PS_RUN)
207
208        # Water regen
209        if self.overloaded:
210            self.water = min(self.water_capacity, self.water + 0.5 * self.regen_rate * dt)
211            if self.water >= self.water_capacity:
212                self.overloaded = False
213        else:
214            self.water = min(self.water_capacity, self.water + self.regen_rate * dt)
215
216        self._anim_t += dt
217        self._water_frame += dt * 10
218
219        # Heat from fires + ambient.
220        self._update_heat(dt)
221
222        # Death check
223        if self.temperature >= self.max_temperature and self.state != PS_DEAD:
224            self._set_state(PS_DEAD)
225
226        self._update_visual()
227
228    # ----------------------------------------------------------- running
229
230    def _update_running(self, dt: float):
231        right = Input.is_action_pressed("right")
232        left = Input.is_action_pressed("left")
233        both = right and left
234        changed_dir = False
235
236        if (not both and right) or (both and self.last_dir == 1):
237            self.xspeed += RUN_ACCEL * dt
238            if self.dir == -1:
239                self.dir = 1
240                changed_dir = True
241            self.last_dir = 1
242        elif (not both and left) or (both and self.last_dir == -1):
243            self.xspeed -= RUN_ACCEL * dt
244            if self.dir == 1:
245                self.dir = -1
246                changed_dir = True
247            self.last_dir = -1
248
249        cap = MAX_SPEED_CARRY if self.state == PS_CARRY else MAX_SPEED
250        self.xspeed = max(-cap, min(cap, self.xspeed))
251
252        if changed_dir and self.gundir == GD_HORIZONTAL:
253            self.stream_length = 0
254
255        # Brake when no input
256        if not (left or right):
257            if self.xspeed > 0:
258                self.xspeed -= min(BRAKE_SPEED * dt, self.xspeed)
259            elif self.xspeed < 0:
260                self.xspeed -= max(-BRAKE_SPEED * dt, self.xspeed)
261
262        # Move x
263        self.position = Vec2(self.position.x + self.xspeed * dt, self.position.y)
264        if self._collides_x():
265            self.position = Vec2(self.position.x - self.xspeed * dt, self.position.y)
266            self.xspeed = 0
267
268        # Gravity
269        self.yspeed += GRAVITY * dt
270        self.position = Vec2(self.position.x, self.position.y + self.yspeed * dt)
271        if self._collides_y():
272            # Snap up
273            if self.yspeed > 0:
274                # Landed on something: snap to top of cell.
275                cy = int((self.position.y - 1) // TILE_SIZE)
276                self.position = Vec2(self.position.x, cy * TILE_SIZE)
277                self.on_ground = True
278            else:
279                self.position = Vec2(self.position.x, self.position.y - self.yspeed * dt)
280            self.yspeed = 0
281        else:
282            self.on_ground = False
283
284        if self.on_ground:
285            self._coyote = COYOTE_TIME
286
287        # Civilian-grab proximity flag
288        self.can_grab = False
289        for c in self._civilians:
290            if c.alive and c.can_grab and self.collides_box(c.aabb()):
291                self.can_grab = True
292                break
293
294    def _collides_x(self) -> bool:
295        if self._grid is None:
296            return False
297        x, y, w, h = self.aabb()
298        return self._grid.collides_box(x, y, w, h)
299
300    def _collides_y(self) -> bool:
301        if self._grid is None:
302            return False
303        x, y, w, h = self.aabb()
304        return self._grid.collides_box(x, y, w, h)
305
306    # ----------------------------------------------------------- climbing
307
308    def _update_climbing(self, dt: float):
309        # Move up / down the ladder.
310        ny = self.position.y
311        if Input.is_action_pressed("up"):
312            ny -= CLIMB_SPEED * dt
313            self._anim_t += dt
314        elif Input.is_action_pressed("down"):
315            ny += CLIMB_SPEED * dt
316            self._anim_t += dt
317        self.position = Vec2(self.position.x, ny)
318
319        # Step off the ladder sideways onto an adjacent floor (upstream
320        # leaveLadder): pressing left/right carries the player off the rungs.
321        if Input.is_action_pressed("right") or Input.is_action_pressed("left"):
322            d = 1 if Input.is_action_pressed("right") else -1
323            self.dir = d
324            self.last_dir = d
325            self.xspeed = d * MAX_SPEED * 0.5
326            self.yspeed = 0.0
327            self._set_state(self._climb_return)
328            return
329
330        # Drop back off the ladder once the whole body has cleared it (the
331        # multi-point probe keeps us climbing until our feet leave the rungs).
332        if not self._on_ladder():
333            self.yspeed = 0.0
334            self._set_state(self._climb_return)
335
336    # ----------------------------------------------------------- gun + stream
337
338    def _update_gun(self, dt: float):
339        old = self.gundir
340        self.gundir = GD_HORIZONTAL
341        if Input.is_action_pressed("up"):
342            self.gundir = GD_UP
343        elif Input.is_action_pressed("down"):
344            self.gundir = GD_DOWN
345        if self.gundir != old:
346            self.stream_length = 0
347
348        # Fire
349        if Input.is_action_pressed("shoot") and not self.overloaded:
350            self.shooting = True
351            self.stream_length = min(self.stream_length + STREAM_SPEED * dt, MAX_STREAM)
352            self.water -= (USE_RATE + self.regen_rate) * dt
353            if self.water <= 0:
354                self.overloaded = True
355        else:
356            # Pressing fire with an empty/overloaded tank gives an audible click
357            # instead of silently swallowing the input.
358            if self.overloaded and Input.is_action_just_pressed("shoot"):
359                self.tank_emptied.emit()
360            self.shooting = False
361            self.stream_length = 0
362            self.stream_collided = False
363            return
364
365        # Wall raycast (tile-by-tile).
366        self.stream_collided = False
367        self._hit_wall = False
368        self._stream_raycast(dt)
369        # ...then the clipped beam is swept against the fire bugs.
370        self._sweep_enemies(dt)
371
372        # Continuous mist at the muzzle so spraying reads as tactile even when
373        # the stream isn't hitting a fire.
374        self.sprayed.emit(self._muzzle_pos(), self._gun_dir_vec())
375        # Splash where the beam smacks a wall.
376        if self._hit_wall:
377            self.wall_splashed.emit(self._stream_tip_pos(), self._gun_dir_vec())
378
379    def _gun_dir_vec(self) -> Vec2:
380        if self.gundir == GD_UP:
381            return Vec2(0.0, -1.0)
382        if self.gundir == GD_DOWN:
383            return Vec2(0.0, 1.0)
384        return Vec2(float(self.dir), 0.0)
385
386    def _muzzle_pos(self) -> Vec2:
387        x, y = self.position.x, self.position.y - 11
388        if self.gundir == GD_UP:
389            return Vec2(x, y - 8)
390        if self.gundir == GD_DOWN:
391            return Vec2(x, y + 8)
392        return Vec2(x + self.dir * 9, y)
393
394    def _stream_tip_pos(self) -> Vec2:
395        m = self._muzzle_pos()
396        d = self._gun_dir_vec()
397        length = float(self.stream_length)
398        return Vec2(m.x + d.x * length, m.y + d.y * length)
399
400    def _stream_raycast(self, dt: float):
401        if self._grid is None:
402            return
403        # Origin in tile coords (offset by upstream values: y-6 for horiz).
404        if self.gundir == GD_HORIZONTAL:
405            origin_y = self.position.y - 11
406            origin_x = self.position.x + self.dir * 9
407            cx, cy = self._grid.cell_for(origin_x, origin_y)
408            span = int((self.stream_length + 12) / TILE_SIZE) + 1
409            for i in range(1, span + 1):
410                cx2 = cx + self.dir * i
411                if self._grid.is_solid(cx2, cy):
412                    if self.dir == 1:
413                        self.stream_length = cx2 * TILE_SIZE - origin_x
414                    else:
415                        self.stream_length = origin_x - (cx2 + 1) * TILE_SIZE
416                    self.stream_collided = True
417                    self._hit_wall = True
418                    break
419                # Hit a fire?
420                if self._fires is not None and self._fires.has_fire(cx2, cy):
421                    self._hit_fire(cx2, cy, dt)
422                    if self._fires is None or not self._fires.has_fire(cx2, cy):
423                        # Extinguished: beam keeps going next frame.
424                        pass
425                    if self.dir == 1:
426                        self.stream_length = (cx2 + 1) * TILE_SIZE - origin_x
427                    else:
428                        self.stream_length = origin_x - cx2 * TILE_SIZE
429                    self.stream_collided = True
430                    break
431        elif self.gundir == GD_UP:
432            origin_x = self.position.x
433            origin_y = self.position.y - PLAYER_H + 4
434            cx, cy = self._grid.cell_for(origin_x, origin_y)
435            span = int((self.stream_length + 12) / TILE_SIZE) + 1
436            for i in range(1, span + 1):
437                cy2 = cy - i
438                if self._grid.is_solid(cx, cy2):
439                    self.stream_length = origin_y - (cy2 + 1) * TILE_SIZE
440                    self.stream_collided = True
441                    self._hit_wall = True
442                    break
443                if self._fires is not None and self._fires.has_fire(cx, cy2):
444                    self._hit_fire(cx, cy2, dt)
445                    self.stream_length = origin_y - cy2 * TILE_SIZE
446                    self.stream_collided = True
447                    break
448        elif self.gundir == GD_DOWN:
449            origin_x = self.position.x
450            origin_y = self.position.y - 4
451            cx, cy = self._grid.cell_for(origin_x, origin_y)
452            span = int((self.stream_length + 12) / TILE_SIZE) + 1
453            for i in range(1, span + 1):
454                cy2 = cy + i
455                if self._grid.is_solid(cx, cy2):
456                    self.stream_length = cy2 * TILE_SIZE - origin_y
457                    self.stream_collided = True
458                    self._hit_wall = True
459                    break
460                if self._fires is not None and self._fires.has_fire(cx, cy2):
461                    self._hit_fire(cx, cy2, dt)
462                    self.stream_length = (cy2 + 1) * TILE_SIZE - origin_y
463                    self.stream_collided = True
464                    break
465
466    def _stream_box(self) -> tuple[float, float, float, float]:
467        """AABB covering the live stream, from muzzle to tip (top-left + size)."""
468        m = self._muzzle_pos()
469        tip = self._stream_tip_pos()
470        thickness = 6.0
471        x0, x1 = sorted((m.x, tip.x))
472        y0, y1 = sorted((m.y, tip.y))
473        if self.gundir == GD_HORIZONTAL:
474            return (x0, y0 - thickness / 2, x1 - x0, thickness)
475        return (x0 - thickness / 2, y0, thickness, y1 - y0)
476
477    def _sweep_enemies(self, dt: float):
478        """Soak any fire bug standing in the beam. Killing one destroys it."""
479        if not self._enemies or self.stream_length <= 0:
480            return
481        bx, by, bw, bh = self._stream_box()
482        for enemy in list(self._enemies):
483            if not enemy.alive:
484                continue
485            ex, ey, ew, eh = enemy.aabb()
486            if bx + bw <= ex or ex + ew <= bx or by + bh <= ey or ey + eh <= by:
487                continue
488            enemy.shoot(dt, self.dir)
489            self.water_hit.emit(Vec2(ex + ew / 2, ey + eh / 2), "hit")
490
491    def _hit_fire(self, cx: int, cy: int, dt: float):
492        if self._fires is None:
493            return
494        # Damage scales with stream length cap relative to MAX (full damage at full stream).
495        damage_dt = dt * 4.0
496        ext = self._fires.shoot(cx, cy, damage_dt)
497        wx = cx * TILE_SIZE + TILE_SIZE / 2
498        wy = cy * TILE_SIZE + TILE_SIZE / 2
499        self.water_hit.emit(Vec2(wx, wy), "extinguish" if ext else "hit")
500
501    # ----------------------------------------------------------- heat
502
503    def _update_heat(self, dt: float):
504        self.heat = 0.0
505        # Touch enemies → heat = 1
506        for e in self._enemies:
507            if e.alive and self.collides_box(e.aabb()):
508                self.heat = 1.0
509                break
510        # Ambient + fire heat
511        self.temperature += TIME_DAMAGE * dt  # slow background tick
512        if self._fires is not None:
513            self.heat = min(1.0, self.heat + self._fires.heat_at(self.position.x, self.position.y))
514        self.temperature += self.heat * BURN_DAMAGE * dt
515        self.temperature = max(0.0, min(self.max_temperature, self.temperature))
516
517    # ----------------------------------------------------------- dead
518
519    def _update_dead(self, dt: float):
520        self.yspeed += GRAVITY * dt
521        self.position = Vec2(self.position.x, self.position.y + self.yspeed * dt)
522
523    # ----------------------------------------------------------- actions
524
525    def action(self, name: str):
526        if self.state == PS_DEAD:
527            return
528        if name == "grab":
529            if self.state == PS_RUN:
530                self._try_grab()
531            elif self.state == PS_CARRY and self.grabbed is not None:
532                self._set_state(PS_THROW)
533                self.grabbed.thrown(self.position.x, self.position.y - 12, self.dir)
534                self.grabbed = None
535            elif self.state == PS_CLIMB:
536                self.yspeed = 0.0
537                self._set_state(self._climb_return)
538
539    def request_jump(self):
540        """Queue a jump press; consumed once grounded (with coyote + buffer)."""
541        if self.state == PS_DEAD:
542            return
543        if self.state == PS_CLIMB:
544            # Hop off the ladder (no launch, matching upstream).
545            self.yspeed = 0.0
546            self._set_state(self._climb_return)
547            return
548        self._jump_buffer = JUMP_BUFFER
549
550    def _consume_jump(self):
551        if self._jump_buffer <= 0:
552            return
553        if self.on_ground or self._coyote > 0:
554            self.yspeed = -JUMP_POWER
555            self.on_ground = False
556            self._coyote = 0.0
557            self._jump_buffer = 0.0
558            self.jumped.emit()
559
560    def hand_over_carried(self):
561        """Give up whoever is being carried, and stop being a carrier.
562
563        Clearing ``grabbed`` alone is not enough: carrying is also a state, and
564        while climbing it is the state the ladder will return to. Both have to
565        go, or the player steps off the rungs as a carrier with empty arms and
566        can never grab again.
567        """
568        carried, self.grabbed = self.grabbed, None
569        self._climb_return = PS_RUN
570        if self.state == PS_CARRY:
571            self._set_state(PS_RUN)
572        return carried
573
574    def _enter_climb(self):
575        """Mount the ladder, snapping to its column centre (upstream climb())."""
576        if self._grid is None:
577            return
578        cx, _ = self._grid.cell_for(self.position.x, self.position.y - PLAYER_H / 2)
579        self.position = Vec2(cx * TILE_SIZE + TILE_SIZE / 2, self.position.y)
580        self._climb_return = PS_CARRY if self.state == PS_CARRY else PS_RUN
581        self._set_state(PS_CLIMB)  # zeroes x/y speed (see _set_state)
582        self.climbed.emit()
583
584    def _on_ladder(self) -> bool:
585        # Probe three points down the body (feet / mid / top) like upstream, so
586        # we stay latched until the FEET clear the rungs rather than popping out
587        # a row early when only the mid-body sample leaves the ladder.
588        if self._grid is None:
589            return False
590        x = self.position.x
591        for oy in (0.0, PLAYER_H / 2, float(PLAYER_H)):
592            cx, cy = self._grid.cell_for(x, self.position.y - oy)
593            if self._grid.is_ladder(cx, cy):
594                return True
595        return False
596
597    def _try_grab(self):
598        for c in self._civilians:
599            if c.alive and c.can_grab and self.collides_box(c.aabb()):
600                self.grabbed = c
601                c.grab()
602                self._set_state(PS_CARRY)
603                return
604
605    # ----------------------------------------------------------- state
606
607    def _set_state(self, state: int):
608        if state == PS_THROW:
609            self._throw_time = 0.4
610        if state == PS_CLIMB:
611            # Settle dead-centre on the rungs: kill all momentum (upstream zeroes
612            # both speeds on entering the climb state).
613            self.xspeed = 0.0
614            self.yspeed = 0.0
615        if state == PS_DEAD:
616            self.yspeed = -180.0  # death pop
617            self.died.emit()
618        self.state = state
619        self.stream_length = 0
620        self._anim_t = 0.0
621
622    # ----------------------------------------------------------- visual
623
624    def _update_visual(self):
625        # Pick frame based on state + animation timer.
626        speed = max(0.001, abs(self.xspeed) / MAX_SPEED)
627        anim_fps = 8.0 * speed if self.state == PS_RUN else 6.0
628        if self.state == PS_DEAD:
629            self._sprite.texture = self._frame_dead
630        elif self.state == PS_CLIMB:
631            idx = int(self._anim_t * anim_fps) % 4
632            self._sprite.texture = self._frames_climb[idx]
633            self._sprite.width = 14
634            self._sprite.height = 23
635        elif self.state == PS_CARRY:
636            idx = int(self._anim_t * anim_fps) % 4
637            self._sprite.texture = self._frames_carry[idx]
638            self._sprite.width = 22
639            self._sprite.height = 32
640        else:
641            idx = int(self._anim_t * anim_fps) % 4
642            self._sprite.texture = self._frames_run[idx]
643            self._sprite.width = 16
644            self._sprite.height = 22
645
646        # Flip horizontally via scale.
647        self._sprite.scale = Vec2(self.dir, 1)
648
649    # ----------------------------------------------------------- draw water
650
651    def on_draw(self, renderer):
652        # Stream water (drawn as filled rect + a tip blob).
653        if self.shooting and self.stream_length > 0:
654            self._draw_stream(renderer)
655        # Gun (small grey marker).
656        self._draw_gun(renderer)
657        # Grab affordance: a bobbing exclamation mark over a grabbable civilian,
658        # cueing the player to press the "grab" key (E).
659        if self.can_grab and self.state == PS_RUN:
660            bob = int(self._water_frame) % 2
661            bx = self.position.x - 1
662            by = self.position.y - PLAYER_H - 9 - bob
663            renderer.draw_rect((bx, by), (2, 5), colour=C.YELLOW, filled=True)
664            renderer.draw_rect((bx, by + 7), (2, 2), colour=C.YELLOW, filled=True)
665
666    def _draw_gun(self, renderer):
667        x, y = self.position.x, self.position.y - 11
668        if self.gundir == GD_HORIZONTAL:
669            gx = x + self.dir * 4
670            renderer.draw_rect((gx - 4, y - 1), (8, 3), colour=C.PLAYER_GUN, filled=True)
671        elif self.gundir == GD_UP:
672            renderer.draw_rect((x - 1, y - 14), (3, 6), colour=C.PLAYER_GUN, filled=True)
673        elif self.gundir == GD_DOWN:
674            renderer.draw_rect((x - 1, y + 2), (3, 6), colour=C.PLAYER_GUN, filled=True)
675
676    def _draw_stream(self, renderer):
677        x, y = self.position.x, self.position.y - 11
678        L = float(self.stream_length)
679        wob = (int(self._water_frame) % 2) * 0.5
680        if self.gundir == GD_HORIZONTAL:
681            sx = x + self.dir * 9
682            if self.dir == -1:
683                renderer.draw_rect((sx - L, y - 4 + wob), (L, 5), colour=C.WATER, filled=True)
684            else:
685                renderer.draw_rect((sx, y - 4 + wob), (L, 5), colour=C.WATER, filled=True)
686            tip_x = sx + self.dir * (L if self.dir == 1 else 0) + (-3 if self.dir == -1 else 0)
687            renderer.draw_circle((tip_x, y - 1.5), 3, colour=C.WATER_TIP, filled=True, segments=8)
688        elif self.gundir == GD_UP:
689            renderer.draw_rect((x - 2.5 + wob, y - 6 - L), (5, L), colour=C.WATER, filled=True)
690            renderer.draw_circle((x, y - 6 - L), 3, colour=C.WATER_TIP, filled=True, segments=8)
691        elif self.gundir == GD_DOWN:
692            renderer.draw_rect((x - 2.5 + wob, y + 4), (5, L), colour=C.WATER, filled=True)
693            renderer.draw_circle((x, y + 4 + L), 3, colour=C.WATER_TIP, filled=True, segments=8)