ECS aplicado a enemigos

En este artículo vamos a explicar la arquitectura ECS aplicado a enemigos, enfocándonos en el desarrollo de videojuegos. Hasta ahora hemos visto qué es ECS, cómo se organiza y por qué puede resultar una alternativa muy interesante a la Programación Orientada a Objetos tradicional. Sin embargo, la verdadera utilidad de esta arquitectura aparece cuando comenzamos a desarrollar elementos reales de un videojuego.

Uno de los mejores ejemplos son los enemigos. En prácticamente cualquier juego encontraremos decenas o incluso cientos de enemigos activos simultáneamente, cada uno con comportamientos diferentes, distintos niveles de vida, armas, inteligencia artificial o patrones de movimiento.

Tradicionalmente, este problema suele resolverse mediante herencia, creando una jerarquía de clases cada vez más profunda. Sin embargo, ECS propone un enfoque completamente diferente: construir los enemigos mediante componentes independientes que posteriormente serán procesados por distintos sistemas.

En este artículo veremos cómo aplico esta filosofía en mis propios videojuegos, utilizando un diseño híbrido que combina la organización de ECS aplicado a enemigos con un código sencillo y fácil de mantener.

El problema de la herencia POO

Como mencionamos en artículos anteriores, una implementación clásica de enemigos en POO podría comenzar así:

Enemy
├── Soldier
├── Archer
├── Mage
├── Boss
├── FlyingEnemy
└── etc

Al principio resulta cómodo. Sin embargo, conforme aparecen nuevos enemigos empiezan los problemas:

  • ¿Qué ocurre si necesitamos un enemigo volador que además sea un jefe?
  • ¿Y si queremos un arquero que también pueda curar?

Poco a poco la jerarquía de clases comienza a crecer hasta convertirse en un árbol difícil de mantener. En muchos casos terminamos sobrescribiendo métodos, duplicando código y haciendo que el proyecto sea cada vez más difícil de gestionar.

ECS y enemigos

En lugar de definir enemigos mediante herencia, ECS los construye a partir de componentes (datos). Por ejemplo, un enemigo puede disponer de:

  • Position
  • Health
  • AI
  • Weapon

Otro enemigo puede tener únicamente:

  • Position
  • Health

Mientras que un jefe podría incorporar además:

  • Shield
  • SpecialAttack
  • Rage

No existe una clase distinta para cada enemigo. Cada uno simplemente posee los componentes que necesita. Esta idea resulta muy interesante y, como personalmente prefiero mantener una arquitectura sencilla, en mis videojuegos utilizo una única clase llamada «systemEnemy».

Esta clase se encarga de crear todos los enemigos mediante datos planos y guardarlos dentro de un único array. Cada posición del array contiene únicamente los datos necesarios para representar un enemigo. Por lo que mi ECS aplicado a enemigos podría verse así:

this.enemies = [
    {
        ID: SOLDIER,
        position: { x: 120, y: 80 },
        health: 100,
        weapon: PISTOL,
        state: PATROL
    },
    {
        ID: ARCHER,
        position: { x: 350, y: 120 },
        health: 60,
        weapon: BOW,
        state: ATTACK
    }
];

Cada objeto representa únicamente datos, mientras que toda la lógica de funcionamiento se encuentra centralizada dentro del sistema encargado de procesarlos.

Tipos de enemigos

En este ECS aplicado a enemigos que estamos implementando, cada enemigo incorpora un identificador ID que indica qué tipo de enemigo representa. Este identificador permite decidir qué comportamiento debe ejecutarse en cada enemigo mediante un simple switch, por ejemplo:

update() {
  for (let i=0; i<this.enemies.length; i++) {
    let enemy = this.enemies[i];
    switch(enemy.ID) {
        case SOLDIER:
            systemSoldier.update(enemy);
            break;

        case ARCHER:
            systemArcher.update(enemy);
            break;

        case BOSS:
            systemBoss.update(enemy);
            break;
    }
  }
}

De esta forma todos los enemigos siguen almacenándose en la misma estructura de datos (this.enemies), mientras que su identificador ID decide qué sistema debe ejecutar su lógica. Es el enfoque que utilizo en mis videojuegos porque mantiene toda la información centralizada, evita crear múltiples jerarquías de clases y facilita añadir nuevos tipos de enemigos.

Crear nuevos enemigos

Una de las mayores ventajas de este enfoque es que añadir un nuevo enemigo resulta muy sencillo. Basta con definir sus datos iniciales y decidir qué sistema/s controlará/n su comportamiento. Si ya hemos visto cómo procesar varios tipos de enemigos en la función update(), podemos crear los enemigos de forma similar. Por ejemplo:

add(ID, data) {
  switch(ID) {
      case SOLDIER:
          // Añadimos datos del tipo SOLDIER
          this.enemies.push({ ... });
          break;

      case ARCHER:
          // Añadimos datos del tipo ARCHER
          this.enemies.push({ ... });
          break;

      case BOSS:
          // Añadimos datos del tipo BOSS
          this.enemies.push({ ... });
          break;
  }
}

A partir del ID del enemigo que se debe crear junto con algunos datos externos opcionales (data), podemos crear cualquier tipo de enemigo y ajustar sus datos con total flexibilidad. Todos comparten la misma estructura general (this.enemies) y lo único que cambia es el sistema que los procesa en la función update() según su ID. Esto evita crear largas cadenas de herencia y permite mantener una arquitectura mucho más flexible.

ECS aplicado a enemigos

Muchos frameworks ECS llevan la separación entre componentes y sistemas al extremo, utilizando gestores de componentes, registros de entidades y numerosas estructuras auxiliares. Personalmente prefiero un enfoque más práctico y mis enemigos continúan almacenándose como estructuras de datos sencillas dentro de un único array, mientras que la lógica permanece organizada y centralizada en la función update().

Con este ECS aplicado a enemigos obtengo buena parte de las ventajas de ECS sin aumentar innecesariamente la complejidad del código. Es un equilibrio entre organización, rendimiento y facilidad de mantenimiento que he ido evolucionando y utilizando desde hace años en mis propios videojuegos.

Como ocurre a lo largo de toda esta serie, mi objetivo no es seguir una implementación ECS completamente ortodoxa, sino adaptar sus principios a las necesidades reales de un proyecto. Si una solución permite desarrollar videojuegos de forma más sencilla, mantener un buen rendimiento y facilitar la evolución del código, considero que ha cumplido su propósito.

Al final, cada maestrillo tiene su librillo, y este ECS aplicado a enemigos es la forma de programar que, tras muchos años desarrollando videojuegos, mejor me ha funcionado.

Este artículo forma parte de la serie ECS: Entidades, Componentes y Sistemas, donde se explica su arquitectura y cómo aplicarla en la programación de videojuegos.

¡ Espero que este artículo sea de vuestro interés !

Logotipo de Safe CreativeLogotipo de Creative Commons Attribution 4.0

Deja un comentario