Este es un pequeño proyecto de ejemplo cuyo objetivo es analizar y construir un sistema de entradas (inputs) utilizando:
- 🧩 Flecs como motor ECS para la gestión de datos.
- 🖱️ SDL2 para manejar la ventana y la entrada de teclado.
La logica de este codigo y el como toma y procesa los inputs se divide en las siguientes etapas :
-
Capturar eventos en buffer
InputEventsBuffer -
Procesar eventos en base a configuracion , Activar flags de inputs
ProcessInputBufferSystem -
Ejecutar acciones en base a las flags
En la primera etapa, nos importa saber : "Que teclas se han presionado?"
Esto lo podemos saber gracias a SDL y su sistema integrado para manejo de eventos.
Cuando tenemos una ventana de SDL, esta en cada ciclo del Bucle Principal estara capturando en eventos tipo SDL_Event los inputs que esta recibiendo la ventana.
Estos Eventos, por buenas practicas, se capturan usando el siguiente codigo basico.
bool isExecuting = true; //Variable de Ejecucion
SDL_Event event; //Variable de Paso para Eventos
while (isExecuting) { // Bucle Principal
while ( SDL_PollEvent(&event) ) { //Bucle de Captura de Eventos
if (event.type == SDL_QUIT) {isExecuting = false;}
}
}En donde :
-
bool isExecuting = true;: Es una variable utilizada para controlar cuando y cuando no queremos ejecutar el Bucle Principal, principalmente para terminar la ejecucion de forma "limpia". -
SDL_Event event;: Sera una variable que usaremos para capturar dinamicamente cada uno de los Eventos de SDL. -
while (isExecuting) { }: Es el bucle principal de la aplicacion, en la cual, en cada ciclo, actualizaremos la logica de nuestro programa. -
while ( SDL_PollEvent(&event) ) { }: Es un sub-bucle, muy usado en SDL en el cual, usando la funcionSDL_PollEvent()traemos toda la lista de eventos y, por cada evento de la lista, guardamos la referencia al evento en la variableevent -
if (event.type == SDL_QUIT) {isExecuting = false;}: Es otra linea de codigo muy usada en SDL, la cual es usada para terminar de forma limpia la ejecucion del programa (saliendo del Bucle Principal) cuando el evento recibido es de tipoSDL_QUIT(Se presiono el boton de "cerrar ventana")
Es, gracias al sub-bucle while ( SDL_PollEvent(&event) ) { } que podemos aprovechar para capturar nuestros Eventos de Inputs, para posteriormente procesarlos.
Siguiendo el principio de dise;o ECS de flecs, lo comodo seria usar : Entidades , Componentes y Sistemas, por lo que todo el dise;o de nuestro codigo estara basado en ello.
La forma mas simple, limpia y elegante para almacenar nuestros Eventos de inputs, seria en un Buffer
Buffer : Espacio de almacenamiento temporal que facilita el acceso a datos a procesar
Nuestro Buffer lo declaramos siguiendo la filosofia de dise;o ECS en el siguiente Componente :
Componente : Estructuras de datos simples y atomicas, sin logica
// components/buffers/InputEventsBuffer.h
struct InputBuffer{
std::vector<SDL_Event> events;
};El cual, simplemente es un Vector (lista dinamica) que usaremos para almacenar nuestra lista de Eventos de SDL.
Este Buffer lo seteamos como un Singleton en nuestro mundo de flecs para facilitar su acceso desde los Sistemas.
flecs::world world; //Creacion del mundo flecs
world.set<InputBuffer>({}); // Seteamos nuestro Buffer a modo de Componente del mundo
Y por ultimo, desde el sub-bucle para la captura de Eventos de SDL, agregamos los Eventos a nuestro Buffer
world.get_mut<InputBuffer>().events.clear(); // Limpiar Buffer
while ( SDL_PollEvent(&event) ) {
if (event.type == SDL_QUIT) {isExecuting = false;}
world.get_mut<InputBuffer>().events.push_back(event);
}Antes de capturar una nueva lista de Eventos, debemos limpiar nuestro Buffer para evitar procesar Eventos antiguos.
world.get_mut<InputBuffer>().events.clear(); // Limpiar BufferEn este punto, para el resto del ciclo de ejecucion de nuestro Bucle Principal ya tendremos nuestra lista de Eventos con los cuales podemos empezar a trabajar
La siguiente etapa de nuestro sistema, es procesar nuestro Buffer de Eventos y aplicar logica el base a ellos.
Para este trabajo de procesamiento de Eventos utilizamos 2 componentes :
-
ConfigurationComponent -
InputFlagsComponent
ConfigurationComponent En el programa, es usado a modo se Singleton de flecs, en el cual, se guardan las configuraciones de teclas, se mapea que tecla de teclado corresponde a cada accion.
struct ConfigurationComponent{
//Configuracion de Inputs de Teclado
int up = SDL_SCANCODE_W;
int down = SDL_SCANCODE_S;
int left = SDL_SCANCODE_A;
int right = SDL_SCANCODE_D;
int lookLeft = SDL_SCANCODE_Q;
int lookRight = SDL_SCANCODE_E;
};En nuestro componente de configuracion, por defecto, se declaran los siguientes inputs :
Movimiento :
- Hacia arriba (
up) - Hacia abajo (
down) - Hacia la izquierda (
left) - Hacia la derecha (
right)
"Camara" :
- Mirar a la izquierda (
lookLeft) - Mirar a la derecha (
lookRight)
Y, usando los valores por defecto del Componente, se declaran las siguientes teclas a las acciones :
Movimiento :
- Hacia arriba (
up) : SDL_SCANCODE_W : TeclaW - Hacia abajo (
down) : SDL_SCANCODE_W : TeclaS - Hacia la izquierda (
left) : SDL_SCANCODE_W : TeclaA - Hacia la derecha (
right) : SDL_SCANCODE_W : TeclaD
"Camara" :
- Mirar a la izquierda (
lookLeft) : SDL_SCANCODE_W : TeclaQ - Mirar a la derecha (
lookRight) : SDL_SCANCODE_W : TeclaE
Esto, nos ayudara a cambiar, facilmente, la asignacion de teclas en nuestro programa, y desacopla totalmente los eventos de las teclas, a las acciones de nuestro programa.
InputFlagsComponent En el programa, es un Componente que almacena valores Booleanos que indica que acciones se estan activando desde el input.
Valores Booleanos : Valores que solo pueden ser "verdaderos" o "falsos", como el interruptor de la luz (encendido o apagado).
struct InputFlagsComponent{
bool up = false;
bool down = false;
bool left = false;
bool right = false;
bool lookLeft = false;
bool lookRight = false;
};Usando el Componente anterior con las asignaciones de las teclas a las acciones, de forma basica, cuando el usuario presione una tecla, se compara si la tecla presionada es una de las accciones establecidad, y en caso de ser asi, se activa la "flag" que indicaria que accion se esta realizando
En este punto posiblemente se esten preguntando : Por que necesitaria un Componente que me diga que accion se esta realizando?
La respuesta se divide en 2 :
-
Acoplamiento
-
Input Lag
Acoplamiento : si mas adelante, en la ejecucion del programa, el Sistema de flecs que se encarga de ejecutar las acciones, las realiza en base a directamente los Eventos de inputs del teclado, estariamos acoplando la logica de la verificacion de teclas, a la logica de ejecucion de las acciones, por lo que estariamos incumpliendo uno de los principios Clean Code :
Tus funciones (sistemas en este caso) solo deben hacer una cosa
Input Lag : Cuando presionas una tecla en tu teclado, el sistema operativo, mete un "Delay" (retardo) a las acciones que hacen las teclas del teclado, para evitar sobrecargar el sistema.
Por lo que, si analizamos directamente los Eventos del teclado directamente en la logica que ejecuta las acciones de las mismas, habria peque;as pausas al momento de presionar una tecla, y de forma grafica, se "veria" de la siguiente forma :
w ... w . w . w . w . w . w . w . w . w . w
Y esto, no nos funciona, almenos no es un motor de videojuego, donde la entrada del usuario es de los factores mas importantes (por no decir el mas importante)
Al usar Flags Booleanas, hacemos que, cuando se presione una tecla, se active un interruptor que indicara que se esta presionando la tecla, y solo se desactivara cuando soltemos la tecla asignada, de esta forma, todo el tiempo que nuestro dedo este pulsando la tecla, la logica que ejecuta las acciones no se vera afectada por los retrasos del teclado, y la accion se ejecutara de la forma mas fluida posible :
wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww
Estos 2 Componentes son almacenados a modo de Singletons en el mundo de flecs para facilitar su acceso :
world.set<ConfigurationComponent>({});
world.set<InputFlagsComponent>({}); Una vez entendida la logica detras de nuestros Componentes para las entradas del teclado, debemos conocer la logica encargada de capturar nuestros Eventos
y para ello, usamos el siguiente Sistema de flecs :
Sistema : Funciones las cuales solo ejecutan codigo en base a los Componentes de las Entidades
world.system<>("ProcessInputBuffer")
.kind(phase)
.run([](flecs::iter& it){
auto world = it.world();
auto buffer = world.singleton<InputBuffer>().get<InputBuffer>();
auto& input = world.singleton<InputFlagsComponent>().get_mut<InputFlagsComponent>();
auto config = world.singleton<ConfigurationComponent>().get<ConfigurationComponent>();
for ( const auto& event : buffer.events ) {
if (event.type != SDL_KEYDOWN && event.type != SDL_KEYUP) continue;
bool isPressed = (event.type == SDL_KEYDOWN);
auto scancode = event.key.keysym.scancode;
if ( scancode == config.up ) input.up = isPressed;
if ( scancode == config.down ) input.down = isPressed;
if ( scancode == config.left ) input.left = isPressed;
if ( scancode == config.right ) input.right = isPressed;
if ( scancode == config.lookLeft ) input.lookLeft = isPressed;
if ( scancode == config.lookRight ) input.lookRight = isPressed;
}
});Este Sistema puede dar miedo, y parecer complejo, pero es bastante simple.
En este Sistema, de forma corta, realizamos las siguientes acciones :
-
Obtenemos el Buffer de Eventos del teclado.
-
Obtenemos los Componentes de las "Flags de Accion" (
InputFlagsComponent) y de configuracion (ConfigurationComponent) -
Comparamos los Eventos de las teclas presionadas, con las teclas asignadas a las acciones, y activando las flags correspondientes
Usando la siguiente linea de codigo :
auto buffer = world.singleton<InputBuffer>().get<InputBuffer>();Obtenemos la referencia al Buffer que fue, previamente, almacenado a modo de Singleton en el mundo de flecs (world)
2. Obtenemos los Componentes de las "Flags de Accion" (InputFlagsComponent) y de configuracion (ConfigurationComponent)
Los Componentes de "Flags de Inputs" (InputFlagsComponent) y de configuracion (ConfigurationComponent), tambien son Singletons, por lo que podemos obtenerlos de la misma manera :
auto& input = world.singleton<InputFlagsComponent>().get_mut<InputFlagsComponent>();
auto config = world.singleton<ConfigurationComponent>().get<ConfigurationComponent>();CON LA GRAN DIFERENCIA, de que el Componente de las "Flags de Inputs" lo debemos de obtener a modo de "Puntero Mutable" (que podamos modificar la data directamente en el Componente original)
Para recorrer cada uno de los Eventos de SDL2 almacenados en el Buffer, usamos un bucle "For-Each"
for ( const auto& event : buffer.events ) {
//codigo...
}Dentro del cual, vamos a comparar, uno a uno los Eventos en el Buffer, para verificar si coincide con una de las teclas asginadas en la configuracion
for ( const auto& event : buffer.events ) {
if (event.type != SDL_KEYDOWN && event.type != SDL_KEYUP) continue;
bool isPressed = (event.type == SDL_KEYDOWN);
auto scancode = event.key.keysym.scancode;
if ( scancode == config.up ) input.up = isPressed;
if ( scancode == config.down ) input.down = isPressed;
if ( scancode == config.left ) input.left = isPressed;
if ( scancode == config.right ) input.right = isPressed;
if ( scancode == config.lookLeft ) input.lookLeft = isPressed;
if ( scancode == config.lookRight ) input.lookRight = isPressed;
}if (event.type != SDL_KEYDOWN && event.type != SDL_KEYUP) continue;
Este bloque IF evita que se hagan comparaciones inutiles cuando no se esta presionando ninguna tecla en el Evento dado.
bool isPressed = (event.type == SDL_KEYDOWN);
Esta linea, facilita saber si la accion del Evento es si se esta presionando, o si se esta soltando una tecla, facilitando el cambio de estado de las Flags.
auto scancode = event.key.keysym.scancode;
Esta variable temporal, almacena el codigo de la tecla presionada, facilitando el codigo de cada bloque IF y facilitando la lectura.
if ( scancode == config.up ) input.up = isPressed;
Uno a uno cada Evento del Buffer pasara por cada uno de estos bloques IF, los cuales analizan si el Evento (en este punto, almacenado solo el valor de la tecla en el scancode) pertenece a una tecla asignada en la configuracion (config) correspondiente a una accion (.up), activando o desactivando la Flag dependiendo si el Evento es de "presionar" o "soltar" la tecla (input.up = isPressed)
Una vez, gracias a nuestro Componente de "Flags de Inputs" (InputFlagsComponent), podemos ejecutar acciones basadas en que teclas se estan presionando, crear nuevas acciones y asignarle teclas especificas, o incluso agregar soporte a otro tipo de Inputs como un Gamepad
Para el peque;o ejemplo de este proyecto, creo un peque;o cuadro rojo el cual se movera a lo largo de la ventana y cambiara su movimiento dependiendo de hacia donde este "mirando"
Para ello, primero creo una peque;a Entidad a la cual le dare unos Componentes de "Posicion" , "Velocidad" , "Angulo de Vision" , y una peque;a Tag que indica que nuestra entidad es nuestro jugador (PlayerTag)
Entidad : Objeto que solo almacena un ID que lo identifique en un mundo ECS, a esta, se le asignaran Componentes, con los cuales se realizaran acciones en base a los Sistemas
flecs::entity player = world.entity("Player")
.set(PositionComponent{100,-100}) // Posicion
.set(AngleOfViewComponent{90}) // Angulo de Vision
.set(VelocityComponent{100}) // Velocidad
.add<PlayerTag>(); // Tag / EtiquetaA esta entidad, se le va a modificar su Posicion y Angulo de Vision en base a un Sistema, que leera las Flags de las acciones presionadas, y modificara los Componentes de nuestra Entidad jugador en base a ellas :
world.system<PositionComponent , AngleOfViewComponent , const VelocityComponent>("PlayerInput").with<PlayerTag>()
.kind(phase)
.run([](flecs::iter& it) {
while (it.next()){
auto input = it.world().singleton<InputFlagsComponent>().get<InputFlagsComponent>();
auto p = it.field<PositionComponent>(0);
auto a = it.field<AngleOfViewComponent>(1);
auto v = it.field<const VelocityComponent>(2);
for (auto i : it){
auto e = it.entity(i);
double angle = degToRad(a[i].value);
double leftStriffeAngle = degToRad(a[i].value + 90);
double rightStriffeAngle = degToRad(a[i].value - 90);
//! Position
//Go forward
if (input.up == true && !input.down ) {
p[i].x += cos(angle) * v[i].value * it.delta_time();
p[i].y += sin(angle) * v[i].value * it.delta_time();
}
//Go backyard
if (input.down == true && !input.up ) {
p[i].x -= cos(angle) * v[i].value * it.delta_time();
p[i].y -= sin(angle) * v[i].value * it.delta_time();
}
//Strife Left
if (input.left == true && !input.right ) {
p[i].x += cos(leftStriffeAngle) * v[i].value * it.delta_time();
p[i].y += sin(leftStriffeAngle) * v[i].value * it.delta_time();
}
//Strife Right
if (input.right == true && !input.left ) {
p[i].x += cos(rightStriffeAngle) * v[i].value * it.delta_time();
p[i].y += sin(rightStriffeAngle) * v[i].value * it.delta_time();
}
//! Vision Angle
if (input.lookLeft == true && !input.lookRight ) a[i].value = fixAngle(a[i].value + (90 * it.delta_time()));
if (input.lookRight == true && !input.lookLeft ) a[i].value = fixAngle(a[i].value - (90 * it.delta_time()));
}
}
});En donde, dentro de nuestro Sistema, realizamos las sigueintes acciones :
auto input = it.world().singleton<InputFlagsComponent>().get<InputFlagsComponent>();
Obtenemos el Singleton con las Flags que indicaran que Teclas estamos presionando / Acciones estamos realizando
auto p = it.field<PositionComponent>(0);
auto a = it.field<AngleOfViewComponent>(1);
auto v = it.field<const VelocityComponent>(2);Obtenemos directamente los Componentes de nuestra Entidad de forma que podamos modificar su data interna
double angle = degToRad(a[i].value);
double leftStriffeAngle = degToRad(a[i].value + 90);
double rightStriffeAngle = degToRad(a[i].value - 90);Calculamos (en Radianes) el angulo al que esta viendo nuestra Entidad y los angulos de sus laterales
//Go forward
if (input.up == true && !input.down ) {
p[i].x += cos(angle) * v[i].value * it.delta_time();
p[i].y += sin(angle) * v[i].value * it.delta_time();
}
//Go backyard
if (input.down == true && !input.up ) {
p[i].x -= cos(angle) * v[i].value * it.delta_time();
p[i].y -= sin(angle) * v[i].value * it.delta_time();
}
//Strife Left
if (input.left == true && !input.right ) {
p[i].x += cos(leftStriffeAngle) * v[i].value * it.delta_time();
p[i].y += sin(leftStriffeAngle) * v[i].value * it.delta_time();
}
//Strife Right
if (input.right == true && !input.left ) {
p[i].x += cos(rightStriffeAngle) * v[i].value * it.delta_time();
p[i].y += sin(rightStriffeAngle) * v[i].value * it.delta_time();
}
//! Vision Angle
if (input.lookLeft == true && !input.lookRight ) a[i].value = fixAngle(a[i].value + (90 * it.delta_time()));
if (input.lookRight == true && !input.lookLeft ) a[i].value = fixAngle(a[i].value - (90 * it.delta_time()));Alteramos los valores de los Componentes de nuestra Entidad en base a que acciones se estan realizando (Cambiar su posicion , Cambiar su angulo de vision)
En cada uno de los bloques IF que verifican las acciones a realizar, se hace otra comparacion que verifica que la accion opuesta no este avtiva (ejemplo : input.up == true && !input.down )), Esto se hace para evitar que ocurran conflictos entre los 2 movimientos opuestos de nuestra Entidad, en caso de que se esten presionando 2 teclas opuestas a la vez (arriba y abajo || izquierda y derecha || mirar a la izq y mirar a la der), se omite completamente la accion.
En el Sistema anterior, se usa una variable llamada Delta Time
El DeltaTime, es la cantidad de tiempo (normalmente, en milisegundo o micras de segundo) de cuanto tiempo ah pasado entre ciclo y ciclo del Bucle Principal, esta se usa para normalizar los cambios de valores sin importar la velocidad de Ciclos por segundo que tenga el Bucle Principal (Ticks/s).
Por ejemplo :
Si decimos que en nuestro Bucle Principal, tenemos una variable a la que, queremos que sume 10 por cada segundo transcurrido, si lo hacemos de la siguiente manera :
int numero = 0;
while (true) {
numero += 10;
}Lo que enrealidad estaria pasando, es que se le sumara a la variable, 10 por cada CICLO del Bucle Principal, por lo que si el Bucle Principal ejecuta 10 Ciclos por segundo, en realidad se sumaria a nuestra variable un total de 100 por segundo, y si el Bucle Principal ejecuta 20 ciclos por segundo, serian 200 por segundo que se le sumen a la variable.
En un videojuego esto esta mal, por que los cambios de los valores de nuestras Entidades dependerian de la velocidad a la que corra el Bucle Principal.
Para solucionar esto, se usa el DeltaTime, que al inicio de cada Ciclo del Bucle Principal, calcula cuanto tiempo ah pasado entre Cilo y Ciclo :
//Inicializar el DeltaTime
double deltaTime = 0.0;
Uint64 now = SDL_GetPerformanceCounter();
Uint64 last = now;
double freq = static_cast<double>(SDL_GetPerformanceFrequency());
//Dentro del Bucle Principal
while (isExecuting) {
//Calcular cuanto tiempo ah pasado entre Ciclo y Ciclo
Uint64 current = SDL_GetPerformanceCounter();
deltaTime = static_cast<double>(current - last) / freq;
last = current;
//...
}ya teniendo el DeltaTime, podemos pasarselo como argumento a la ejecucion de nuestro PipeLine de flecs
world.run_pipeline( InputPipeline , deltaTime );Pipeline (en flecs) : Es una lista de Sistemas a ejecutar
Yo en este Proyecto, separo 3 PipeLines personalizados :
auto InputPipeline = world.pipeline().with(flecs::System).with(OnInput).build();
auto UpdatePipeline = world.pipeline().with(flecs::System).with(flecs::OnUpdate).build();
auto RenderPipeline = world.pipeline().with(flecs::System).with(OnRender).build();Por que ?
Yo divido la logica de actualizacion en 3 :
-
Calculo de Inputs (
InputPipeline) : Cosas como el procesamiento de los Eventos de Inputs, o actualizar la posicion de jugador, lo ejecuto en un PipeLine que corre a la misma velocidad Maxima del Bucle Principal (FPS) para evitar lo maximo posible el Input Lag -
Actualizacion de Mundo (
UpdatePipeline) : Cosas como el calculo de la IA o demas calculos a dentro del mundo que se hace de forma automatica por el juego, los separo en un PipeLine aparte que se va a ejecutar solo 20 veces por segundo. -
Renderizado (
RenderPipeline) : Dibujar todo lo necesario en pantalla, por organizacion, lo hago en un PipeLine aparte, que se va a ejecutar a la misma velocidad del Bucle Principal (FPS) y despues de hbaer hechos los cambios en todas las entidades del mundo en el juego.
Yo en el codigo de este proyecto, no tengo declarada la logica del UpdatePipeline y no hago el renderizado en el RenderPipeline, debido a que simplemente este proyecto es un ejemplo de como se crea un sistema de Inputs basico.