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)