Juego para CMD/terminal - "Terminal Blocks"



Cómo crear un juego para CMD/terminal usando Python

En este artículo vamos a crear un juego de terminal, es decir, sin interfaz gráfica. Pero no crear que va a ser un juego de texto, este juego si tendrá una interfaz, pero la contruiremos a pelo, sin opengl ni nada por el estilo. Sin utilizar librerías ni frameworks para el desarrollo de videojuegos.

Este artículo no pretende ser una guía para el desarrollo de videojuegos con python, simplemente quiero mostrar cómo es posible hacer cosas chulas con muy pocos recursos, siendo los límites de lo que hagamos nuestra propia imaginación.

El juego que vamos a crear consiste en una pantalla rectangular en la que el jugador se encuentra en la parte inferior. De la parte superior itán cayendo una serie de enemigos/objetos que el jugador tiene que esquivar.

He escrito este artículo porque estoy empezando a estudiar Godot Game Engine y pensé: "a ver hasta donde llego utilizándo solamente el cmd/terminal"

Empezaremos por importar los módulos necesarios para crear el juego:

import os
import time
import cursor                                    # pip install cursor
import random
import threading
from colorama import Back, init; init()          # pip install colorama
from pynput.keyboard import Key, Listener        # pip install pynput

Si se produce algún error al instalar los módulos necesarios te recomiendo crear un virtualenv. En windows no he tenido problemas, pero en uno de mis equipos con ubuntu tuve que usar sudo para instalar pynput, cosa poco recomendable (en su lugar trabaja con un entorno virtual si es necesario).

Voy a explicar cómo desarrollar este jueguecito paso a paso, pero si quieres puedes ir directamente al código final del juego y copiarlo. Con el código a mano y si quieres entender cómo funciona puedes seguir el resto del artículo desde aquí. Espero que llegues al final


La clase Game

Lo primero que vamos a hacer es crear una clase Game que va a contener la funcionalidad del juego:

class Game:

    PLAYER_SYMBOL = 'T'
    ENEMY_SYMBOL = 'V'
    SPACE_SYMBOL = ' '

    def __init__(self, rows, columns):
    	
    	self.rows = rows
    	self.columns = columns

    	self.score = 0
    	self.vidas = 10

Esta clase será instanciada posteriormente.

Como puedes observar, hemos definido las constantes PLAYER_SYMBOL, ENEMY_SYMBOL y SPACE_SYMBOL, que son los caracteres que representan al jugador, enemigos y espacio respectivamente.

Por otra parte, su método __init__ recibe dos parámetros: rows y columns, que utilizaremos para definir las dimensiones del lienzo del juego. Asignamos su valor a atributos con el mismo nombre para utilizarlos posteriormente en los distintos métodos de la clase:

self.rows = rows
self.columns = columns

A continuación hemos definido los atributos score y vidas para, valga la redundancia, dar algo de vida al juego.

En el método __init__ hay que invocar a los métodos que definiremos en el próximo apartado: prepare y display. De modo que nuestra clase Game por el momento debe quedar así:

class Game:

    PLAYER_SYMBOL = 'T'
    ENEMY_SYMBOL = 'V'
    SPACE_SYMBOL = ' '

    def __init__(self, rows, columns):
    	
    	self.rows = rows
    	self.columns = columns

    	self.score = 0
    	self.vidas = 10

    	self.prepare()
    	self.display() # lo sustituiremos luego por: self.start()

    def prepare(self):
    	pass

    def display(self):
    	pass

Para ejecutar el juego (aunque todavía no hace nada) hay que instanciar a Game pasándole los argumentos necesarios. Por ejemplo:

if __name__ == '__main__':

    Game(rows=20, columns=40)

Métodos prepare y display


self.prepare

Este método solamente será invocado una vez (dentro de __init__) para dibujar el lienzo inicial del juego. Tenemos que conseguir lo siguiente:

Empecemos a definir el método prepare:

def prepare(self):

    self.screen = []

    for i in range(self.rows):
    	pass

En este momento, self.screen es una lista vacía que queremos llenar con otras listas. self.screen debe contener tantas listas como habíamos especificado en self.rows, por lo que creamos un bucle que itera sobre cada elemento de range(self.rows para conseguirlo.

Cada lista añadida a self.screen debe tener una longitud igual a self.columns, y cada uno de sus elementos inicialmente va a ser el definido en self.SPACE_SYMBOL:

def prepare(self):

    self.screen = []

    for i in range(self.rows):
    	row = list(self.SPACE_SYMBOL) * self.columns
    	self.screen.append(row)

En el juego que estamos creando siempre se va a cumplir len(self.screen) == self.rows, así como len(self.screen[n]) == self.columns.

Cada elementos de un self.screen[n] es, inicialmente, el valor de self.SPACE_SYMBOL

A continuación vamos a definir la lista self.player_row, que es la fila por la que se moverá el jugador:

self.player_row = list(self.SPACE_SYMBOL) * self.columns

En este momento, self.player_row es una lista idéntica a las que contiene self.screen. Tenemos que sustituir alguno de los elementos que contiene (self.SPACE_SYMBOL) por self.PLAYER_SYMBOL. Por ejemplo:

self.player_row = list(self.SPACE_SYMBOL) * self.columns
self.player_row.insert(0, self.PLAYER_SYMBOL)
self.player_row.pop(-1)

Así hemos añadido al jugador a la lista, en la primera posición. Al hacerlo hemos eliminado el último elemento de la lista para que se siga cumpliendo la condición len(self.player_row) == self.columns. La longitud de las listas en este juego siempre deberá permanecer constante.

Sin embargo, queda mucho mejor si el jugador empieza en el centro de self.player_row. En este caso, la posición inicial del jugador (que vamos a definir como init_pos) puede obtenerse y aplicarse así:

init_pos = int(len(self.player_row)/2)
self.player_row.insert(init_pos, self.PLAYER_SYMBOL)

Quedando el método prepare finalmente de este modo:

def prepare(self):

    self.screen = []

    for i in range(self.rows):
    	row = list(self.SPACE_SYMBOL) * self.columns
    	self.screen.append(row)

    self.player_row = list(self.SPACE_SYMBOL) * self.columns
    init_pos = int(len(self.player_row)/2)
    self.player_row.insert(init_pos, self.PLAYER_SYMBOL)
    self.player_row.pop(-1)

self.display

Este método va a encargarse de mostrar en pantalla el estado del juego. El estado del juego está definido por el valor de self.screen y el de self.player_row.

Este método será ejecutado en bucle para dar la sensación de dinamismo al juego.

Comencemos a definir el método:

def display(self):

    os.system('cls' if os.name == 'nt' else 'clear') # clear

Lo primero que va a hacer este método cada vez que se ejecute es limpiar la pantalla, es decir, borrar todo lo que se había imprimido previamente. Esto puede hacerse mediante os.system('cls' if os.name == 'nt' else 'clear'). No olvides importar el módulo os: import os.

Lo siguiente que haremos será mostrar el score y las vidas:

print(f'SCORE: {self.score}                    VIDAS: {self.vidas}')
print('-'*(self.columns*2+1)) # para crear una línea divisoria

A continuación hay que mostrar el contenido de self.screen:

for row in self.screen:
    print('|' + self.SPACE_SYMBOL.join(row) + '|')

Así, para cada lista (row) dentro de self.screen hemos mostrado cada uno de sus elementos concatenados, siendo el separador self.SPACE_SYMBOL. Los caracteres '|' los utilizamos para delimitar la zona de juego (lo verás posteriormente).

Volviendo atrás, ahora puedes comprender el por qué de la sentencia print('-'*(self.columns*2+1)). Se multiplica self.columns*2 debido a que estamos concatenando con el separador self.SPACE_SYMBOL, de modo que el ancho de la zona de juego es el doble del de columnas (y se resta 1 porque el separador no se añade depués del ultimo elemento cuando utilizamos self.SPACE_SYMBOL.join).

Finalmente hacemos lo mismo para self.player_row, quedando el método self.display así:

def display(self):

    os.system('cls' if os.name == 'nt' else 'clear') # clear

    print(f'SCORE: {self.score}                    VIDAS: {self.vidas}')
    print('-'*(self.columns*2+1))

    for row in self.screen:
    	print('|' + self.SPACE_SYMBOL.join(row) + '|')

    print('|' + self.SPACE_SYMBOL.join(self.player_row) + '|')
    print('-'*(self.columns*2+1))

Por fin el programa hace algo, aunque todavía no podemos decir que esto sea un juego. Si lo ejecutas verás lo siguiente:

Juego Terminal Blocks Iniciado

Arriba se muestra el SCORE y las VIDAS. Debajo está la zona de juego (que cambiará al cambiar self.screen y self.player_row).

Fíjate en que en el centro de self.player_row aparece una T que representa al jugador (es el valor de self.PLAYER_SYMBOL). Vamos a cambiar este valor para que el juego no consista solamente en letras que se mueven:

Ejecuta el programa y verás cómo ha cambiado la representación del jugador:

Juego Terminal Blocks - Jugador Coloreado

El cambio no ha sido demasiado impresionante, y el juego todavía no hace nada. Pero paciencia, cuando funcione verás que esta pequeña modificación luce bastante bien

Por cierto, dentro del método self.display ahora puedes cambiar os.system('cls' if os.name == 'nt' else 'clear') por print('\033[H\033[J'). Esto solamente funciona si se está utilizando colorama (después de invocar a colorama.init()). A mi personalmente me gusta mas esta solución, ya que utilizando os.system realmente estamos invocando a un programa externo, y prefiero ser autosuficiente jeje.


Método new_wave

En nuestro juego irán apareciendo enemigos desde la parte superior. Los enemigos irán avanzado hacia abajo y el jugador tendrá que esquivarlos antes de que colisionen con él.

En el método self.new_wave hay que crear una nueva lista (de la longitud adecuada) e insertarla como primer elemento de self.screen. Al hacer esto, para que el número de listas no cambia, hay que eliminar la última de las listas:

def new_wave(self):

    wave = list(self.SPACE_SYMBOL) * self.columns

    # Añadir enimigos a la wave

    self.screen.pop(-1)
    self.screen.insert(0, wave)

Pero la wave tiene que llevar enemigos para que sea una amenaza. Por ejemplo, para añadir un enemigo en una posición aleatoria haríamos esto:

random_pos = random.randrange(0, len(wave)) # no olvides: import random
wave[random_pos] = self.ENEMY_SYMBOL

No olvides importar al módulo random: import random

Pero, ¿cuantos enemigos añadimos a la wave?. Me parece razonable que el número de enemigos esté relacionado con self.columns. Haciendo algunas pruebas he llegado a la conclusión de un buen número de enemigos por oleada puede ser la octava parte de la lóngitud de la oleada, y como la longitud de la oleada es igual a self.columns:

def new_wave(self):

    wave = list(self.SPACE_SYMBOL) * self.columns

    for i in range(int(self.columns/8)):
    	random_pos = random.randrange(0, len(wave))
    	wave[random_pos] = self.ENEMY_SYMBOL

    self.screen.pop(-1)
    self.screen.insert(0, wave)

Para ver como funcionará el juego "modifica temporalmente" el programa así:

if __name__ == '__main__':

    game = Game(rows=20, columns=40)
    for i in range(100):
    	game.new_wave()
    	game.display()
    	time.sleep(0.1) # no olvides: import time

Ejecuta el juego y mira lo que pasa:

Terminal Blocks - Animación provisional

Podemos cambiar el self.ENEMY_SYMBOL así:

ENEMY_SYMBOL = Back.RED + " " + Back.RESET

Y el juego se verá ahora de esta manera:

Terminal Blocks - Animación provisional


Método main_loop

Define un método self.main_loop así:

def main_loop(self):

    while self.vidas > 0:
    	self.new_wave()
    	self.display()
    	#self.check_collision()
    	time.sleep(0.1)

    self.display()
    os._exit(0)

El métodos self.check_collision lo he comentado porque todavía no lo hemos definido. Como puedes observar, main_loop es un bucle que permanece activo hasta que el jugador se quede sin vidas. Al quedarse sin vidas se sale del bucle, se muestra por ultima vez el resultado (self.display()) y se sale del programa (os._exit(0)). Antes de salir del programa invocamos a display una última vez para que quede constancia de que el jugador se ha quedado sin vidas: VIDAS: 0.

"modifica temporalmente" el programa así:

if __name__ == '__main__':

    game = Game(rows=20, columns=40)
    game.main_loop()

Y comprobarás que funciona exactamente igual que antes, pero ahora el juego nunca se detiene, ya que self.vidas permanece constante y su valor es mayor que 0.

Puesto que self.main_loop invoca a self.display, puedes eliminar la *invocación a self.display en `self.__init`*__


Método check_collision

Aquí hay que comprobar si un enemigo está colisionando con el jugador. Esto lo haremos obteniendo el índice en self.player_row que contiene a self.PLAYER_SYMBOL:

player_position = self.player_row.index(self.PLAYER_SYMBOL)

Ahora que tenemos la posición el jugador tenemos que comprobar si en la última lista dentro de self.screen, en el mismo índice donde encontramos al jugador, hay un enemigo. La última lista de self.screen es self.screen[-1]:

player_position = self.player_row.index(self.PLAYER_SYMBOL)

if self.screen[-1][player_position] == self.ENEMY_SYMBOL: # colisión
    pass

¿que hacemos en caso de una colisión? tenemos que quitar una vida al jugador. Además podemos emitir un sonido beep. Si no se produce una colisión aprovechamos para aumentar el valor de self.score. El método queda finalmente así:

def check_collision(self):
    player_position = self.player_row.index(self.PLAYER_SYMBOL)

    if self.screen[-1][player_position] == self.ENEMY_SYMBOL:
    	self.vidas -= 1
    	print('\a', end='')
    else:
    	self.score += 1

No olvides descomentar a self.check_collision() dentro del método self.main_loop.

Ejecuta el juego y observa cómo va aumentando el valor de SCORE mientras no se producen colisiones. Cuando se produce una colisión suena beep y se resta una vida al jugador. Cuando se cumple self.vida == 0 el programa finaliza.

Ya sólo falta que el jugador se pueda mover!


Método start

Este método pondrá en marcha al juego, así que cambia lo que sucede después de if __name__ == '__main__': así:

if __name__ == '__main__':

    cursor.hide() # Evita que se muestre el cursor aleatoriamente en zonas del juego al actualizar la escena (mientras se ejecuta cualquier print)
    Game(rows=20, columns=40)

El método start va a invocar a dos métodos: self.main_loop y self.keyboard_actions. Cómo todavía no hemos definido el segundo de estos dos métodos, crea un método vacía por el momento:

def keyboard_actions(self, key):
    pass

Ahora empieza a definir el método start así:

def start(self):
    threading.Thread(target=self.main_loop, daemon=True).start() # no olvides: import threading

Dentro del método __init__, la última sentencia debe ser la invocación de start: self.start()

Ejecutamos a self.main_loop como un Thread aparte para poder escuchar la entrada del teclado sin que el bucle que actualiza la pantalla lo impida.

Bien, ahora hay que usar a keyboard_actions, que será un callback para un listener de pynput:

def start(self):

    threading.Thread(target=self.main_loop, daemon=True).start()

    with Listener(on_press=self.keyboard_actions) as listener: # no olvides: from pynput.keyboard import Key, Listener
        listener.join()

El programa funciona "aparentemente" igual que antes, pero ya estamos listos para el toque final: definir keyboard_actions


Método keyboard_actions

Este método no será invocado directamente, sino que es un callback para el listener que hemos creado dentro de self.start.

Su definición completa es la siguiente:

if key == Key.left or key == Key.right:

    old_pos = self.player_row.index(self.PLAYER_SYMBOL)
    new_pos = old_pos

    if key == Key.left and old_pos != 0: # Si se pulsa <- y el jugador no está en 0

    	new_pos = old_pos - 1

    elif key == Key.right and old_pos != len(self.player_row)-1: #Si se pulsa -> y el jugador no está en la última posición
    	
    	new_pos = old_pos + 1

    if new_pos != old_pos:
    	self.player_row.insert(new_pos, self.player_row.pop(old_pos))

Creo que se explica por si sola. En caso de que la tecla pulsada sea la flecha izquierda o derecha obtenemos la posición del jugador (old_pos). Si se ha pulsado la flecha izquierda, el valor de la nueva posición será el de la anterior menos 1, y si se pulsó la flecha derecha es el valor de la posición anterior mas 1.

En caso de que new_pos != old_pos se extrae el elemento en old_pos mediante self.player_row.pop(old_pos) y se asigna a la new_pos:

self.player_row.insert(new_pos, self.player_row.pop(old_pos))

Ejecuta el programa y ¡JUEGA!, ya has terminado el juego

Pulsa las flechas del teclado y comprueba cómo magicamente funciona jeje.

Terminal Blocks - Animación Final del Juego


CÓDIGO FINAL DEL JUEGO

import os
import time
import cursor
import random
import threading
from colorama import Back, init; init()
from pynput.keyboard import Key, Listener


class Game:

    PLAYER_SYMBOL = Back.CYAN + " " + Back.RESET
    ENEMY_SYMBOL = Back.RED + " " + Back.RESET
    SPACE_SYMBOL = ' '

    def __init__(self, rows, columns):
    	
    	self.rows = rows
    	self.columns = columns

    	self.score = 0
    	self.vidas = 10

    	self.prepare()
    	self.start()


    def prepare(self):
    	"""Inicialización de self.screen y self.player_row"""

    	self.screen = []

    	for i in range(self.rows):
    		row = list(self.SPACE_SYMBOL) * self.columns
    		self.screen.append(row)

    	self.player_row = list(self.SPACE_SYMBOL) * self.columns
    	init_pos = int(len(self.player_row)/2)
    	self.player_row.insert(init_pos, self.PLAYER_SYMBOL)
    	self.player_row.pop(-1)


    def display(self):
    	"""Función que muestra en pantalla el estado del juego"""
    	
    	os.system('cls' if os.name == 'nt' else 'clear') # clear

    	print(f'SCORE: {self.score}                    VIDAS: {self.vidas}')
    	print('-'*(self.columns*2+1))

    	for row in self.screen:
    		print('|' + self.SPACE_SYMBOL.join(row) + '|')

    	print('|' + self.SPACE_SYMBOL.join(self.player_row) + '|')
    	print('-'*(self.columns*2+1))


    def new_wave(self):
    	"""Creación de una nueva oleada de enemigos"""

    	wave = list(self.SPACE_SYMBOL) * self.columns

    	for i in range(int(self.columns/8)):
    		random_pos = random.randrange(0, len(wave))
    		wave[random_pos] = self.ENEMY_SYMBOL

    	self.screen.pop(-1)
    	self.screen.insert(0, wave)


    def check_collision(self):
    	"""Comprobación de colisiones con enemigos y actuación en consecuencia"""

    	player_position = self.player_row.index(self.PLAYER_SYMBOL)

    	if self.screen[-1][player_position] == self.ENEMY_SYMBOL:
    		self.vidas -= 1
    		print('\a', end='')
    	else:
    		self.score += 1


    def main_loop(self):
    	"""Bucle principal que actualiza el estado del juego"""

    	while self.vidas > 0:
    		self.new_wave()
    		self.display()
    		self.check_collision()
    		time.sleep(0.1)

    	self.display()
    	os._exit(0)


    def keyboard_actions(self, key):
    	"""Control de acciones en base a la pulsación de teclas"""

    	if key == Key.left or key == Key.right:

    		old_pos = self.player_row.index(self.PLAYER_SYMBOL)
    		new_pos = old_pos

    		if key == Key.left and old_pos != 0: # Si se pulsa <- y el jugador no está en 0

    			new_pos = old_pos - 1

    		elif key == Key.right and old_pos != len(self.player_row)-1: #Si se pulsa -> y el jugador no está en la última posición
    			
    			new_pos = old_pos + 1

    		if new_pos != old_pos:
    			self.player_row.insert(new_pos, self.player_row.pop(old_pos))


    def start(self):
    	"""Inicialización del juego: self.start se ejecuta en un Thread separado y va actualizando el estado del juego. se asigna self.keyboard_actions como callback de un Listener del Keyboard"""

    	threading.Thread(target=self.main_loop, daemon=True).start()

    	with Listener(on_press=self.keyboard_actions) as listener: # no olvides: from pynput.keyboard import Key, Listener
    	    listener.join()




if __name__ == '__main__':

    cursor.hide() # Ocultamos el cursor para que no moleste
    Game(rows=20, columns=40) # QUE TE DIVIERTAS :D