benmoran56 / esper

An ECS (Entity Component System) for Python
MIT License
537 stars 67 forks source link

A way to index and get Component with parameters #55

Closed Lehnart closed 3 years ago

Lehnart commented 3 years ago

Hey,

With an ECS architecture, I always have a PositionComponent in my game. This PositionComponent is quite simple, 2 integers representing a (x,y) position.

When using this component, I every time do something like :

  for ent,pos in self.world.get_component(PositionComponent):
      if pos.x() == x and pos.y() == y: 
          do_something()

It is not really performant neither elegant code.

I'd like a way to access specifically entities with a PositionComponent with given x and y. More technically, I think it could be interesting to have components that can be indexed by a parameter. In my example, the parameter will be (x,y). I guess the code would become something like :

  for ent,pos in self.world.get_indexed_component(PositionComponent, (x,y)):
      do_something()

I didn't think about the technical implementation, just laying the idea here :)

benmoran56 commented 3 years ago

To better understand your request, could you tell me:

  1. Does the (x, y) position change, or is it static?
  2. If it does change, how often? Every frame, or just occasionally?
Lehnart commented 3 years ago
  1. It changes: a processor, MoveProcessor for example, update the position depending on input, event, ai etc.
  2. It may change at each input, but never at each frame.
benmoran56 commented 3 years ago

If it's not happening every frame, you might want to think about a temporary Component. For example, consider this Processer which lerps an Entity towards a position. The Component is temporary, and is removed after the movement is finished:

class WaypointProcessor(esper.Processor):
    """Lerp towards a position"""

    @staticmethod
    def lerp(source, target, factor):
        return (target - source) / factor

    def process(self, dt):
        for ent, (rend, wayp) in self.world.get_components(Renderable, Waypoint):

            sprx, spry = rend.sprite.position

            sprx += self.lerp(sprx, wayp.x, wayp.speed)
            spry += self.lerp(spry, wayp.y, wayp.speed)

            if abs(wayp.x - sprx) < 1 and abs(wayp.y - spry) < 1:
                sprx = wayp.x
                spry = wayp.y
                self.world.remove_component(ent, Waypoint)

            rend.sprite.position = sprx, spry
Lehnart commented 3 years ago

Thanks for the detailed solution. It effectively solves a part of my problem.

However, I still have the problem for example if I want to check if I can move to the destination or if there is an enemy, a wall, etc. Here is a snippet of code I'm using:

class MoveProcessor(esper.Processor):

    def process(self, dt):
        messages: List[MoveEvent] = self.world.receive(MoveEvent)
        for msg in messages:
            ent, move = msg.entity, msg.movement

            # If there is an enemy on destination, I don't want to move but I want to fight.
            fighting_entities = self.get_entities_at(x + dx, y + dy, FighterComponent)
            if self.world.has_component(ent, FighterComponent) and fighting_entities:
                self.world.publish(FightEvent(ent, fighting_entities[0]))
                continue

            # If the destination is not _movable_, nothing happens
            if not self.get_entities_at(x + dx, y + dy, MovableComponent):
                continue

            # else, I can move to (x,y). I omit the code here.
            ...
    def get_entities_at(self, x: int, y: int, *component_types):
        entities = []
        for ent, (pos, *_) in self.world.get_components(PositionComponent, *component_types):
            if pos.xy() == (x, y):
                entities.append(ent)
        return entities

In this case, where I want to know what is at a given position before doing an action, I have to check the components of the entity at (x,y). I'm not sure temporary components will help me with this. I'll be glad if you have a proposition to handle this :).

benmoran56 commented 3 years ago

There are several ways to do this, but putting the logic inside of esper wouldn't be the best solution. For enemies that are moving each frame, you really don't have much choice but to iterate over all of them each frame. If the enemies only move sometimes, then you could add a HasMoved component to them, at the time of movement. Then the below code could only return those enemies.

class CollisionProcessor(esper.Processor):

    @staticmethod
    def _check_overlap(body_a, body_b):
        .....

     def process(self, dt):
        for ent, (player, rend, vel) in world.get_components(Player, Renderable, Velocity):

            for eent, (_, erend, evel) in world.get_components(Enemy, Renderable, Velocity):

                if self._check_overlap(rend, erend):
                    do_something()

For static things, like walls, etc., a good idea is to implement some kind of Spatial Hashing. This is expensive to create, but very fast to query. A good idea is to do this query only at the start of a level/map. Static objects would have a StaticBody Component. Something like:

class CollisionProcessor(esper.Processor):
    def __init__(self):
        self.spatial_hash = None

    @staticmethod
    def _check_overlap(body_a, body_b):
        ....

    def process(self, dt):
        world = self.world
        if self.spatial_hash is None:
            bodies = [body for (ent, body) in world.get_component(StaticBody)]
            self.spatial_hash = Space(bodies=bodies)

        for ent, (rend, vel) in world.get_components(Renderable, Velocity):
            """Check Scene collisions for all moving entities."""
            for hit_body in self.spatial.get_hits(rend.aabb):
                do_correction_for_collision_with_walls()
Lehnart commented 3 years ago

Ok got it! Thanks for your answer.