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:
- Una propiedad
self.screen
debe ser una lista de listas. Cada una de las listas que contiene se dibujará en pantalla, una debajo de la otra - Una propiedad
self.player_row
debe ser una lista que se dibujará en pantalla depués de haberse dibujado cada una de las listas enself.screen
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:
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:
- Importa la librería colorama e inicializalá:
from colorama import Back, init; init()
- Sustituye:por:
PLAYER_SYMBOL = 'T'
PLAYER_SYMBOL = Back.CYAN + " " + Back.RESET
Ejecuta el programa y verás cómo ha cambiado la representación del jugador:
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:
Podemos cambiar el self.ENEMY_SYMBOL
así:
ENEMY_SYMBOL = Back.RED + " " + Back.RESET
Y el juego se verá ahora de esta manera:
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.
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