nodes/enemy.pyΒΆ

Part of Mr. Rescue.

  1"""FireBug: single enemy type (port collapses upstream's 6 to one).
  2
  3Walks left/right on a floor, spawns fires periodically. Knockback on hit.
  4"""
  5
  6from __future__ import annotations
  7
  8import random
  9
 10from simvx.core import Node2D, Signal, Sprite2D, Vec2
 11
 12from . import textures
 13from .tile_grid import TILE_SIZE, TileGrid
 14
 15EN_RUN = 0
 16EN_HIT = 1
 17EN_RECOVER = 2
 18
 19MOVE_SPEED = 60.0
 20GRAVITY = 350.0
 21MAX_HEALTH = 1.3
 22SCORE = 100
 23RECOVER_TIME = 0.7
 24FIRE_SPAWN_MIN = 8.0
 25FIRE_SPAWN_MAX = 22.0
 26
 27ENEMY_W = 12
 28ENEMY_H = 22
 29
 30
 31class FireBug(Node2D):
 32    killed = Signal(object)  # this FireBug, doused by the water stream
 33    fire_spawned = Signal(Vec2)  # world position of the flame it just lit
 34
 35    def __init__(self, *, position: Vec2, **kwargs):
 36        super().__init__(position=position, **kwargs)
 37        self.alive = True
 38        self.health = MAX_HEALTH
 39        self.dir = random.choice((-1, 1))
 40        self.xspeed = 0.0
 41        self.yspeed = 0.0
 42        self.state = EN_RUN
 43        self._recover_t = 0.0
 44        self._next_fire = random.uniform(FIRE_SPAWN_MIN, FIRE_SPAWN_MAX)
 45
 46        self._frames = textures.get("enemy")
 47        self._anim_t = 0.0
 48        self._sprite = Sprite2D(
 49            texture=self._frames[0],
 50            width=16,
 51            height=22,
 52            filter="nearest",
 53        )
 54        self.add_child(self._sprite)
 55
 56        self._grid: TileGrid | None = None
 57        self._fires = None
 58
 59    def set_world(self, *, grid, fires):
 60        self._grid = grid
 61        self._fires = fires
 62
 63    def aabb(self):
 64        return (self.position.x - ENEMY_W / 2, self.position.y - ENEMY_H, ENEMY_W, ENEMY_H)
 65
 66    def shoot(self, dt: float, dir_: int) -> bool:
 67        """Apply water damage. Returns True if the bug died this hit."""
 68        self.health -= dt * 1.5
 69        self.dir = -dir_
 70        self.state = EN_HIT
 71        self._recover_t = 0.15
 72        if self.health <= 0:
 73            self.alive = False
 74            self.killed.emit(self)
 75            self.destroy()
 76            return True
 77        return False
 78
 79    def on_update(self, dt: float):
 80        if not self.alive or dt <= 0:
 81            return
 82        self._anim_t += dt
 83
 84        if self.state == EN_RUN:
 85            self._update_run(dt)
 86        elif self.state == EN_HIT:
 87            self._recover_t -= dt
 88            self.yspeed += GRAVITY * dt
 89            self._move_y(dt)
 90            if self._recover_t <= 0:
 91                self.state = EN_RECOVER
 92                self._recover_t = RECOVER_TIME
 93        elif self.state == EN_RECOVER:
 94            self._recover_t -= dt
 95            self.yspeed += GRAVITY * dt
 96            self._move_y(dt)
 97            if self._recover_t <= 0:
 98                self.state = EN_RUN
 99
100        # Fire-spawn timer (only while running).
101        if self.state == EN_RUN and self._fires is not None and self._grid is not None:
102            self._next_fire -= dt
103            if self._next_fire <= 0:
104                cx, cy = self._grid.cell_for(self.position.x, self.position.y - 4)
105                if self._fires.spawn(cx, cy):
106                    self.fire_spawned.emit(Vec2(cx * TILE_SIZE + 8, cy * TILE_SIZE + 8))
107                self._next_fire = random.uniform(FIRE_SPAWN_MIN, FIRE_SPAWN_MAX)
108
109        self._update_visual()
110
111    def _update_run(self, dt: float):
112        self.xspeed = self.dir * MOVE_SPEED
113        self.yspeed += GRAVITY * dt
114        if self._grid is None:
115            return
116        # X
117        nx = self.position.x + self.xspeed * dt
118        old = self.position
119        self.position = Vec2(nx, self.position.y)
120        x, y, w, h = self.aabb()
121        if self._grid.collides_box(x, y, w, h):
122            self.position = old
123            self.dir *= -1
124        # Y
125        self._move_y(dt)
126        # Edge sense: flip if no floor 1 tile ahead.
127        ahead_x = self.position.x + self.dir * (ENEMY_W / 2 + 2)
128        cx_ahead = int(ahead_x // TILE_SIZE)
129        cy_below = int((self.position.y + 2) // TILE_SIZE)
130        if not self._grid.is_solid(cx_ahead, cy_below):
131            self.dir *= -1
132
133    def _move_y(self, dt: float):
134        self.position = Vec2(self.position.x, self.position.y + self.yspeed * dt)
135        x, y, w, h = self.aabb()
136        if self._grid is not None and self._grid.collides_box(x, y, w, h):
137            if self.yspeed > 0:
138                cy = int((self.position.y - 1) // TILE_SIZE)
139                self.position = Vec2(self.position.x, cy * TILE_SIZE)
140            self.yspeed = 0
141
142    def _update_visual(self):
143        idx = int(self._anim_t * 6) % 4
144        self._sprite.texture = self._frames[idx]
145        self._sprite.scale = Vec2(self.dir, 1)
146        if self.state == EN_HIT:
147            self._sprite.colour = (1.0, 1.0, 1.0, 0.6)
148        else:
149            self._sprite.colour = (1.0, 1.0, 1.0, 1.0)