En este artículo vamos a explicar cómo usar ECS para crear un sistema de armas y proyectiles universal y reutilizable por todas las entidades. En los artículos anteriores hemos visto cómo organizar enemigos utilizando una arquitectura ECS híbrida basada en datos planos y sistemas especializados. Sin embargo, no todos los elementos de un videojuego presentan el mismo tipo de relaciones entre sus datos.
Un enemigo puede representarse perfectamente mediante una estructura de datos sencilla almacenada en un array y procesada posteriormente por un sistema. Sin embargo, un sistema de armas suele mantener una relación mucho más estrecha con la entidad que lo utiliza, además de interactuar continuamente con otros sistemas, como el gestor de disparos, las colisiones o los efectos visuales.
Por este motivo, en mis videojuegos utilizo una solución diferente: mantengo la filosofía ECS híbrida para enemigos/jugadores, pero encapsulo la gestión de las armas y proyectiles dentro de una clase POO específica. Eso sí, esta clase específica sigue utilizando un sistema ECS híbrido de datos planos y sistemas. No considero que esto rompa la arquitectura; simplemente utilizo Programación Orientada a Objetos y/o ECS allí donde realmente aportan un buen equilibrio entre rendimiento, simplicidad y claridad al código.
Como hemos comentado varias veces a lo largo de esta serie, mi objetivo no es seguir un ECS completamente puro, sino encontrar el equilibrio entre organización, rendimiento y facilidad de mantenimiento.
¿Por qué POO para armas y proyectiles?
Si observamos un enemigo, generalmente todos sus datos son independientes:
- posición
- vida
- estado
- velocidad
- tipo de enemigo
Un sistema puede recorrer cientos de enemigos y actualizar cada uno de ellos sin que exista una dependencia directa con otras entidades. En cambio, con las armas ocurre algo distinto. Cada arma necesita conocer continuamente:
- Quién es su propietario
- Seguir a su propietario en la pantalla
- Hacia dónde está mirando el arma
- Cuándo puede volver a disparar
- Qué tipo de proyectil debe crear
- Qué sistema gestionará dichos proyectiles
En otras palabras, un arma no es únicamente un conjunto de datos aislados, sino un pequeño controlador que coordina varios elementos relacionados entre sí. Esquemáticamente podríamos resumirlo así:
Jugador
↓
weaponSystem
↓
shootSystem
↓
Movimiento
Colisiones
Daño
Debido a esta complejidad, prefiero encapsular toda esa responsabilidad de la gestión de las armas y proyectiles dentro de una única clase POO a la que llamaremos weaponSystem.
ECS híbrido para armas y proyectiles
Aunque utilice una clase POO para controlar las armas y proyectiles como weaponSystem, internamente el funcionamiento sigue siendo un ECS híbrido: cada arma continúa siendo únicamente un conjunto de datos que se guardan en un array. Sin embargo, aquí entran dos nuevos elementos vitales para su correcto funcionamiento: la entidad que posee el arma y el sistema gestor de disparos.
En el constructor de la clase weaponSystem definiremos los datos básicos que necesita para funcionar. Dos de ellos los recibimos a través de parámetros y su código es el siguiente:
constructor(parent, shootSystem) {
this.weapon= [];
this.parent = parent;
this.shootSystem = shootSystem;
}
El objetivo y funcionamiento de estos parámetros de la clase weaponSystem es el siguiente:
- this.weapon: Es el array donde almacenaremos todas las armas que puede tener la entidad relacionada.
- this.parent: Se trata de la entidad relacionada, esta puede ser un jugador/enemigo que puede estar compuesta de varios gráficos (nave, turbinas, escudo, etc). El arma estará vinculada a uno de esos gráficos (por ejemplo la nave) y ese gráfico en concreto será el que debemos pasar como
parent, creando una referencia directa para posicionar el arma correctamente. - this.shootSystem: Se trata del sistema gestor de disparos del videojuego. De este modo, el arma sólo se encarga de su propio funcionamiento, animación y efectuar los disparos según la cadencia del arma. Pero una vez disparado el proyectil, éste pasa al gestor de disparos durante toda la vida del proyectil. En otras palabras, el arma no tiene ni gestiona proyectiles, sólo efectua los disparos.
Para que todo esto funcione correctamente, cada arma debe tener una serie de datos básicos que, de forma simplificada, podríamos estructurar así:
this.weapon = [
{
rate: 0,
mRate: 20,
type: LASER,
pattern: TRIPLE,
icon: {...},
projectile: {...}
},
];
Cada arma almacena únicamente la información necesaria para funcionar. Es decir, seguimos trabajando con datos planos, simplemente encapsulados dentro de la clase weaponSystem responsable de administrarlos. Entre los componentes de cada arma podemos destacar los siguientes:
- rate, mRate: Son las variables encargadas de manejar la cadencia de disparos del arma basado en los ciclos de ejecución del videojuego.
- type: Identifica el tipo de arma, esto es útil para aplicarle configuraciones y actualizaciones individuales a cada arma.
- pattern: Esta variable sirve para crear diferentes patrones de disparos como simple, doble, etc. Junto con la variable
type, permite combinaciones de armas y disparos. - icon: Es un objeto de datos que almacena toda la información necesaria para representar visualmente el arma, como su posición, tamaño, orientación o desplazamiento respecto a la entidad propietaria. Si el arma no dispone de una imagen propia, puede utilizar las dimensiones de
parentpara posicionarse correctamente y se le asigna una función de dibujodraw()vacía (fake), permitiendo que el sistema la procese como un icono gráfico sin realizar comprobaciones adicionales. Esto resulta especialmente útil para representar entidades que disparan directamente, como una nave o un enemigo sin un arma visible. Si por el contrario, icon es un arma visible e independiente, como una torreta adyacente, podemos añadirle datos de vida o salud y que sea destructible. - projectile: contiene todos los datos necesarios para generar cada proyectil, como su tipo, velocidad, daño, imagen gráfica o cualquier otra información requerida para inicializarlo.
NOTA: Si no estás familiarizado con mi librería gráfica y su función nativa de dibujo draw(), puedes echarle un vistazo antes de continuar.
Aunque pueda parecer una diferencia pequeña, encapsular estos datos dentro del weaponSystem simplifica enormemente la gestión del código, especialmente cuando una misma entidad puede disponer de varias armas diferentes.
Creando distintos tipos de armas
Una de las ventajas de este diseño ECS híbrido es que crear y/o modificar nuevas armas y proyectiles resulta muy sencillo. Cada tipo de arma únicamente inicializa sus propios datos y se almacena en una posición concreta del array que llamaremos «slot». De esta forma podemos asignar las armas individualmente a una tecla concreta como (disparo primario, secundario, etc). De igual modo, tal y como hicimos en el artículo ECS aplicado a enemigos, podemos crear o modificar nuestras armas de la siguiente manera:
add(slot, type) {
switch(type){
case LASER:
this.weapon[slot] = { ... };
break;
case MISSILE:
this.weapon[slot] = { ... };
break;
case SHOTGUN:
this.weapon[slot] = { ... };
break;
}
}
Cada arma define sus propios datos de funcionamiento y el slot en el que debe almacenarse. Es responsabilidad del programador y de la lógica del videojuego gestionar los slots de cada entidad.
Uno de los puntos fuertes de este sistema es que cada arma ocupa un slot independiente, lo que permite dispararlas individualmente. Por ejemplo, podemos crear dos armas para un jugador y asociar una tecla distinta a cada slot:
// Iniciamos sistema de armas
player.weapon = new weaponSystem(player.icon, shootSystem);
// Añadimos armas
player.weapon.add(SLOT.PRIMARY, 'LASER');
player.weapon.add(SLOT.SECONDARY, 'MISSILE');
// Disparamos armas
if (Keypress == 'Space') player.weapon.fire(SLOT.PRIMARY);
if (Keypress == 'Tab') player.weapon.fire(SLOT.SECONDARY);
Ajustando las armas y proyectiles a la entidad
Aunque crear un arma parece una tarea fácil, todavía quedan pasos importantes: realizar una serie de ajustes automáticos sobre el arma y los proyectiles que generará. Para ello, durante la creación del arma se inicializan una serie de datos tomando como referencia la entidad propietaria (parent). De forma simplificada:
add(slot, type) {
let weapon = {
...
icon: {...},
projectile: {...}
};
// Ajustar arma al centro de la entidad
weapon.icon.offsetX = (this.parent.width - weapon .icon.width) >> 1;
weapon.icon.offsetY = (this.parent.height - weapon .icon.height) >> 1;
// Ajustar salida del proyectil al arma
weapon.projectile.offsetX = (weapon.icon.width - weapon.projectile.width) >> 1;
weapon.projectile.offsetY = (weapon.icon.height - weapon.projectile.height) >> 1;
// Función fake de dibujo
if (!weapon.icon.img) weapon.icon.draw = function() {};
// Añade el arma al listado
this.weapon[slot] = weapon;
}
Los ajustes que realiza cada proyecto dependerán del tipo de videojuego que se esté desarrollando. En mi caso prefiero realizar cálculos automáticos y mantener el sistema lo más sencillo posible.
- Ajustar arma: Por defecto, el arma se centra automáticamente respecto a la entidad propietaria (
parent). Gracias a ello, cualquier jugador o enemigo puede equipar un arma sin necesidad de realizar cálculos adicionales. - Ajustar proyectil: A continuación, la salida del proyectil se centra automáticamente respecto al arma. Así se establece una jerarquía muy sencilla: el arma se posiciona respecto a
parenty el proyectil respecto al arma. De este modo, ambos permanecen correctamente alineados en todo momento. - Función fake: Si el arma NO dispone de una imagen asociada, se crea una función de dibujo vacía (fake). Gracias a ello, el sistema de renderizado puede recorrer todas las armas y llamar siempre a su función
draw()sin tener que comprobar previamente si existe una imagen que dibujar. Esto simplifica el código y evita realizar comprobaciones innecesarias en cada fotograma.
No obstante, cada arma puede necesitar una posición diferente. Por ejemplo, una espada puede mostrarse desplazada hacia un lado del personaje, mientras que un cañón puede sobresalir por la parte frontal. Para estos casos, podemos añadir al sistema funciones de configuración adicionales. Por ejemplo:
// Ajusta la posición
setWeaponPosition(slot, x, y) {
this.unit[slot].icon.offsetX = x,
this.unit[slot].icon.offsetY = y;
}
// Establece cadencia del arma
setWeaponRate(slot, rate) {
this.unit[slot].rate = rate;
this.unit[slot].mRate = rate;
}
// Establece ángulo del arma
setWeaponAngle(slot, angle) {
this.unit[slot].icon.angle = angle;
}
// Establece patrón de disparo
setWeaponPattern(slot, pattern) {
this.unit[slot].pattern = pattern;
}
Con estos métodos adicionales, podemos ajustar manualmente los parámetros de posición, orientación, cadencia de tiro o cualquier otro parámetro de un arma sin alterar el funcionamiento interno del sistema.
Una vez creada y ajustada un arma, weaponSystem será el encargado de mantenerla sincronizada con su propietario y gestionar todo su funcionamiento.
Sistema de proyectiles
Una vez creada el arma, necesitamos un mecanismo que permita generar los proyectiles. Para ello, la clase weaponSystem ha recibido en su constructor dos dependencias fundamentales:
- parent: la entidad propietaria del arma (jugador, enemigo, jefe, etc.)
- shootSystem: el sistema encargado de gestionar todos los proyectiles del videojuego
Esta separación de responsabilidades resulta muy importante. El sistema de armas únicamente decide cuándo y cómo debe dispararse un proyectil, mientras que toda la lógica relacionada con su movimiento, colisiones o destrucción queda delegada en el sistema de proyectiles.
Para generar los disparos nuestra clase weaponSystem dispone de dos funciones muy sencillas: una que dispara todas las armas de la entidad y otra que gestiona el disparo de un arma concreta. De forma simplificada, el código podría representarse así:
// Dispara todas las armas
fireAll() {
for (let i = 0; i < this.weapon.length; i++) this.fire(i);
}
// Dispara un arma concreta
fire(slot) {
// Obtenemos el arma
let weapon = this.weapon[slot];
// ¿Puede disparar?
if (weapon.rate > 0) return;
weapon.rate = weapon.mRate;
// Datos iniciales del proyectil
let projectile = {
type: weapon.type
projectile.angle = weapon.icon.angle;
projectile.x = weapon.icon.x + weapon.projectile.offsetX;
projectile.y = weapon.icon.y + weapon.projectile.offsetY;
...
};
// Enviar proyectil al sistema de disparos
switch (weapon.pattern) {
case SIMPLE:
this.shootSystem.add(projectile);
break;
case DOUBLE:
projectile.y -= 5; // Separamos 5px arriba
this.shootSystem.add(Object.assign({}, projectile)); // Clonamos para evitar referencias
projectile.y += 10; // Separamos 5px abajo
this.shootSystem.add(projectile);
break;
}
}
La función fireAll() simplemente recorre todas las armas de la entidad y llama a fire() para cada una de ellas. De esta forma un mismo jugador o enemigo puede disparar varias armas simultáneamente sin complicar la lógica del juego. Cada una de ellas disparará respetando su propia cadencia de tiro (rate).
Por su parte, fire() comprueba primero si el arma ya puede volver a disparar mediante su temporizador (rate). Si el arma está preparada, calcula los datos iniciales del proyectil utilizando la posición y orientación actuales de la entidad propietaria (parent) y, finalmente, envía dicho proyectil al shootSystem.
Gracias a ello, los proyectiles siempre nacen en la posición correcta respecto a su arma, pero luego son gestionados por un sistema completamente independiente. El sistema de armas únicamente decide cuándo, cómo y con qué datos iniciales debe dispararse.
Esto también nos permite implementar distintos patrones de disparo con un sencillo switch y una variable pattern. Internamente, toda las armas utilizan la misma función de disparo. La única diferencia consiste en generar uno o varios proyectiles con distintas posiciones o ángulos iniciales antes de enviarlos al sistema de proyectiles.
De esta forma resulta muy sencillo añadir nuevos patrones de disparo o crear nuevas armas sin modificar el resto de la arquitectura.
Miles de proyectiles
Una vez creados los proyectiles y enviados a shootSystem, todos los proyectiles pasan a almacenarse dentro de un único sistema especializado. De forma similar a como vimos con los enemigos, este sistema únicamente recorre un array con todos los disparos activos y actualiza su posición, detecta colisiones y elimina aquellos que ya no son necesarios.
Para mejorar la lógica del videojuegos yo utilizo dos instancias de este sistema, uno para los disparos enemigos y otro para los disparos del jugador. De esta forma cotejar los impactos de los disparos consiste simplemente en comparar un listado u otro. Cada una de estas instancias es la que debe enviarse como parámetro en shootSystem y asegurarse de que tenga el método add() para recibir los proyectiles.
Además, esta organización permite manejar cientos o incluso miles de proyectiles simultáneamente manteniendo un código muy sencillo. Aunque cuando un videojuego dispara continuamente, crear y destruir objetos en cada disparo termina generando trabajo adicional para el recolector de basura (Garbage Collector).
En este sentido, una solución muy habitual consiste en utilizar un Object Pool. En lugar de crear nuevos proyectiles constantemente, el sistema reutiliza proyectiles ya existentes que han dejado de utilizarse. De esta forma se evita crear y destruir objetos continuamente, disminuye el trabajo del recolector de basura y el rendimiento se mantiene mucho más estable.
Aunque esto es un tema externo a la serie ECS, es un factor de optimización importante que podemos implementar de forma independiente dentro de nuestro shootSystem, y sin que afecte al resto de nuestro videojuego, gracias a la separación de responsabilidades y sistemas.
Actualizando las armas
Al igual que cualquier otro sistema del videojuego, un sistema de armas debe actualizarse en cada ciclo de juego. Para ello, la clase dispone de una función update() que recorre todas las armas almacenadas y actualiza su estado. De forma simplificada, podría representarse así:
update() {
for (let i = 0; i < this.weapon.length; i++) {
let weapon = this.weapon[i];
// Actualizar temporizador de disparo
if (weapon.rate > 0) weapon.rate--;
// El arma sigue a su entidad
weapon.icon.x = parent.x + weapon.icon.offsetX;
weapon.icon.y = parent.y + weapon.icon.offsetY;
// Actualizar según tipo de arma
switch (weapon.type) {
case MISSILE:
// Actualizar datos
break;
}
}
}
Una de las tareas más importantes consiste en sincronizar continuamente cada arma con la entidad propietaria (parent). De este modo, cuando un jugador o un enemigo se desplaza, gira o cambia de dirección, todas sus armas actualizan automáticamente su posición y/o orientación para mantenerse acopladas a la entidad.
Además, la función update() también puede encargarse de realizar cualquier otra actualización específica de cada arma, por ejemplo:
- Animar el arma mientras dispara
- Modificar su posición para simular retroceso (recoil)
- Aplicar oscilaciones o movimientos automáticos
- Actualizar armas giratorias o satélites
- Recargar automáticamente determinados tipos de armas.
De esta forma, cada arma mantiene su propio estado interno sin que el resto del videojuego tenga que preocuparse por ello. El jugador o el enemigo únicamente necesitan actualizar su sistema de armas, mientras que este se encarga de mantener sincronizadas y actualizadas todas las armas que poseen.
player.weapon.update()
De la misma manera podemos renderizar todas las armas mediante el método player.weapon.draw() el cual simplemente recorre el listado de armas y las dibuja con los datos actualizados en update().
draw() {
for (let i=0; i<this.unit.length; i++) this.unit[i].icon.draw();
}
Este enfoque mantiene el código organizado y permite añadir nuevos comportamientos a las armas sin modificar el resto de la arquitectura del videojuego. Después de haber probado varias soluciones, me decanto claramente por este sistema por, una vez más, ser el que mejor equilibrio ofrece entre rendimiento, simplicidad y claridad en el código.
Conclusiones ECS para armas y proyectiles
Aunque a primera vista este sistema ECS de armas y proyectiles pueda parecer complejo, en realidad cada una de sus partes tiene una responsabilidad muy concreta. El weaponSystem administra las armas de una entidad, mientras que el sistema de proyectiles sootSystem gestiona todos los disparos activos. Esta separación de responsabilidades permite construir una arquitectura organizada, sencilla de mantener y fácil de ampliar sin renunciar a un buen rendimiento.
Podría parecer que utilizar una clase POO para gestionar las armas y proyectiles contradice la filosofía ECS, pero en mi experiencia ocurre justo lo contrario. Las armas mantienen una relación muy estrecha con la entidad que las utiliza y coordinan continuamente información con otros sistemas del videojuego. Encapsular toda esa coordinación dentro de una única clase POO utilizando un ECS híbrido, evita dispersar la lógica y simplifica considerablemente el diseño.
Además, weaponSystem no es simplemente un gestor de disparos. Actúa como un auténtico sistema de armas: administra todas las armas que posee una entidad, mantiene sincronizados sus datos, controla su actualización y decide cuándo y cómo deben generarse los proyectiles. Internamente sigue utilizando datos planos organizados en un array, por lo que, en cierto modo, constituye un pequeño ECS de armas y proyectiles especializado.
Este es precisamente el enfoque que utilizo en mis videojuegos. ECS continúa siendo la base para organizar entidades como jugadores o enemigos, mientras que determinadas partes con dependencias muy directas se benefician de una implementación orientada a objetos. No se trata de abandonar ECS, sino de decidir cuándo resulta conveniente salir de un enfoque completamente puro para encapsular determinadas responsabilidades sin romper la arquitectura general.
Como he comentado en artículos anteriores, no creo que exista una única metodología válida para todos los proyectos. Cada herramienta tiene su lugar y debe utilizarse cuando realmente aporta valor. Si una solución facilita el desarrollo, mantiene un buen rendimiento y hace que el código sea más sencillo de ampliar y evolucionar, para mí es la solución adecuada.
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 !
