Desarrollo de videojuegos multiplataforma con Python

Como crear videojuegos con el Intérprete de Python "Brython" y el Framework para JavaScript "Phaser3"


INTRODUCCIÓN


Como jugador de videojuegos pensé que había llegado la hora de aprender a crear los míos propios, y como entusiasta de Python lo ideal para mí sería programarlos en este lenguaje.

Python tiene muy buenas librerías para crear videojuegos (pygame, pyglet, arcade, kivent...) pero lo ideal sería crear juegos con un framework para todas las plataformas, es decir, quería crear juegos que fuesen tanto para escritorio como para el navegador y dispositivos móviles.

Sin duda la mejor opción hubiese sido descartar Python como lenguaje para este propósito, y en su lugar optar por JavaScript, ya que con este es posible crear aplicaciones y juegos para el navegador, y es posible crear aplicaciones móviles y de escritorio cuya interfaz gráfica sea un Webview.

Sin embargo no disfruto nada programando en JavaScript y por el contrario me encanta Python.

Tenía que hacer algo...
No todo estaba perdido...


EL JUEGO PONG


El juego que muestro a continuación ha sido programado completamente en Python y se ejecuta del lado del cliente en el navegador web:

PLAYER 1
PLAYER 2
0
0

[ Cuando un jugador puntúe, pulsa la tecla ENTER para lanzar la bola de nuevo ]


¿Como es posible?

Es posible gracias a Brython, el intérprete de Python para el navegador escrito en JavaScript.

He utilizado el Framework Phaser3, que es un framework para el desarrollo de videojuegos con JavaScript, pero el juego ha sido escrito completamente con código Python.


PREPARANDO EL JUEGO


Antes de comenzar a programar el juego con Python tenemos que desplegar un servidor y escribir un poco de html y css

  1. Crea una carpeta llamada "juego" y en su interior crea tres archivos: index.html, index.css y game.py
  2. Inicia un servidor web local desde un terminal en la carpeta "juego": python -m http.server
  3. Abre localhost:8000 en un navegador web.

index.html

El contenido del archivo index.html debe ser el siguiente:

<DOCTYPE html>

<html lang="es">

<head>

    <meta charset='utf-8'/>

    <!-- Cargamos Phaser y Brython -->
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/brython-dev/brython/www/src/brython.js"></script>

    <!-- Cargamos la hoja de estilos -->
    <link rel="stylesheet" href="index.css"/>

</head>

<!-- Iniciamos Brython al cargarse el cuerpo del documento -->
<body onload="brython();">

    <!-- Definimos el contenedor del juego -->
    <div id="gamebox"></div>

    <!-- Cargamos el juego -->
    <script type="text/python3" src="game.py"></script>

</body>

</html>

index.css

Damos estilo al contenedor de nuestro juego, el div con id="gamebox":

#gamebox{
    width:100vh;
    height:100vh;
    display:flex;
    justify-content:center;
    align-items:center;
}

Hemos establecido que #gamebox ocupe todo el ancho y alto de la pantalla, y que su contenido se encuentre centrado tanto horizontal como verticalmente (así el juego renderizado estará en el centro de la pantalla).

game.py

Escribiremos nuestro juego en el archivo game.py, pero antes vamos a familiarizarnos un poco con Phaser desde el punto de vista de Brython.


PHASER EN BRYTHON

En Brython es posible acceder a los atributos del objeto global de javascript (window) mediante el objeto browser.window. de modo que para acceder a Phaser desde Brython haremos lo siguiente:

from browser import window

Phaser = window.Phaser

Phaser tiene una Clase Game que se utiliza para iniciar el juego y una Clase Scene para crear las distintas escenas del juego. Un Phaser.Game manejará una o varias Phaser.Scene.

Cada una de las Phaser.Scene debe tener al menos cuatro métodos que se ejecutan secuencialmente al iniciar la escena: init, preload, create y update. El método update es el bucle que dará vida al juego.

Podríamos implementar un juego de la siguiente forma:

from browser import window
Phaser = window.Phaser


def init(*args):
    print('init ejecutado')

def preload(*args):
    print('preload ejecutado')

def create(*args):
    print('create ejecutado')

def update(time, delta):
    print('update ejecutado')


# DEFINIMOS LA ESCENA
scene = dict(

    init=init,
    preload=preload,
    create=create,
    update=update

    )

# INICIAMOS EL JUEGO
Phaser.Game.new(dict(

    width=640,
    height=480,
    type=Phaser.CANVAS,
    parent='gamebox',
    scene=[scene]

    ))

Nuestro juego (Phaser.Game) unicamente maneja una Phaser.Scene a la que hemos identificado como scene:

Caso 1: Creación de la Escena con un diccionario (no recomendado)

En este caso es necesario utilizar this para referirse a las escena dentro de sus propios métodos init, preload, create y update (y otros que tú definas). En Brython podemos obtener el objeto al que apunta this dentro del método (el objeto propietario de la función en términos de javascript) mediante el uso del módulo javascript de brython:

from browser import window
import javascript # NO OLVIDES IMPORTAR EL MÓDULO

Phaser = window.Phaser


def init(*args):
    this = javascript.this()
    # utiliza "this" para referirte a la escena y utilizar sus métodos

def preload(*args):
    this = javascript.this()
    # utiliza "this" para referirte a la escena y utilizar sus métodos

def create(*args):
    this = javascript.this()
    # utiliza "this" para referirte a la escena y utilizar sus métodos

def update(time, delta):
    this = javascript.this()
    # utiliza "this" para referirte a la escena y utilizar sus métodos



scene = dict(

    init=init,
    preload=preload,
    create=create,
    update=update

    )

# scene no es una Phaser.Scene, sino un diccionario que utilizará Phaser.Game
# para crear una Phaser.Scene, por lo que necesitamos acceder a la Phaser.Scene
# mediante javascript.this() dentro de sus propios métodos oara acceder
# a los atributos y métodos de la escena y así desarrollar el videojuego.


# INICIAMOS EL JUEGO
Phaser.Game.new(dict(

    width=640,
    height=480,
    type=Phaser.CANVAS,
    parent='gamebox',
    scene=[scene]

    ))

Caso 2: Creación de la escena con Phaser.Scene (recomendado, pero hay una forma mejor)

En este caso no necesitamos usar this, ya que la variable scene apunta directamente a la Phaser.Scene, es decir, scene es la escena:

from browser import window
Phaser = window.Phaser


def init(*args):
    # en lugar de "this" utiliza "scene" para referirte a la escena
    pass

def preload(*args):
    # en lugar de "this" utiliza "scene" para referirte a la escena
    pass

def create(*args):
    # en lugar de "this" utiliza "scene" para referirte a la escena
    pass

def update(time, delta):
    # en lugar de "this" utiliza "scene" para referirte a la escena
    pass


scene = Phaser.Scene.new('scene')
scene.active = True
scene.init = init
scene.preload = preload
scene.create = create
scene.update = update

# scene es una Phaser.Scene


# INICIAMOS EL JUEGO
Phaser.Game.new(dict(

    width=640,
    height=480,
    type=Phaser.CANVAS,
    parent='gamebox',
    scene=[scene]

    ))

El this de javascript no funciona igual que el self de python, por lo que recomiendo evitar usarlo.

Pero lo suyo sería utilizar clases Python que hereden de Phaser.Scene, ¿no?

Efectivamente, si no utilizamos clases y herencia crearemos un código dificil de mantener. Lo que hemos visto hasta ahora puede valer para crear juegos pequeños, pero sin utilizar clases y herencia será muy difícil crear algo mas que pequeños ejemplos como el pong de esta página.

La mala noticia es que no es posible crear clases python que hereden de clases javascript.

Estuve a punto de tirar de surrender, pero...

¡descubrí la Herencia Fake!


HERENCIA FAKE


Prepárate porque vamos a crear una clase que nos va a permitir trabajar con Phaser como si se tratase de una librería Python.

Estuve un par de días estrujándome la cabeza para dar en el clavo. Presta atención a la definición de la Clase Scene:

class Scene:

    def __init__(self, active=True):

        self.this = Phaser.Scene.new(self.__class__.__name__)
        self.this.active = active
        self.this.init = self.init
        self.this.preload = self.preload
        self.this.create = self.create
        self.this.update = self.update

    def __getattr__(self, attr):

        if attr not in self.__dict__:
            return self.this[attr]

    def __setattr__(self, attr, value):

        self.__dict__[attr] = value
        self.this[attr] = value

    def __call__(self):
        return self.this

A partir de esta clase crearás las escenas de tu juego mediante herencia: Ahora es como si self fuese this y te voy a explicar por qué:

Observa el método __init__ de la Clase Scene que definimos previamente:

def __init__(self, active=True):

    self.this = Phaser.Scene.new(self.__class__.__name__)
    self.this.active = active
    self.this.init = self.init
    self.this.preload = self.preload
    self.this.create = self.create
    self.this.update = self.update

Al instanciar un objeto de la Clase Scene, el método __init__ crea el atributo self.this, que es una Phaser.Scene cuyo nombre es el de la propia Clase Scene (o el de la clase que herede de Scene):

self.this = Phaser.Scene.new(self.__class__.__name__)

A continuación indicamos si la escena self.this es o no activa, siendo el valor por defecto True. Finalmente asignamos los métodos init, preload, create y update de la Clase Scene (o de la clase que herede de Scene) a la escena self.this.

La magia necesaria para utilizar self como si fuese self.this se encuentra en la definición de los métodos especiales __getattr__ y __setattr__:

Finalmente, definimos el método especial __call__ para que al invocar a la instancia de la clase nos devuelva a self.this:

def __call__(self):
    return self.this

Ya puedes crear una escena mediante "herencia fake" siendo self la instancia de la clase pero además funcionando como la escena en sí misma:

class MainScene(Scene):

    def __init__(self, active=True):
        Scene.__init__(self, active)

    def init(self, *args):
        pass

    def preload(self, *args):
        pass

    def create(self, *args):
        pass

    def update(self, time, delta):
        pass

Finalmente, la base de código en game.py para crear nuestro juego es la siguiente:

from browser import window
Phaser = window.Phaser


class Scene:

    def __init__(self, active=True):

        self.this = Phaser.Scene.new(self.__class__.__name__)
        self.this.active = active
        self.this.init = self.init
        self.this.preload = self.preload
        self.this.create = self.create
        self.this.update = self.update

    def __getattr__(self, attr):

        if attr not in self.__dict__:
            return self.this[attr]

    def __setattr__(self, attr, value):

        self.__dict__[attr] = value
        self.this[attr] = value

    def __call__(self):
        return self.this



class MainScene(Scene):

    def __init__(self, active=True):
        Scene.__init__(self, active)

    def init(self, *args):
        pass

    def preload(self, *args):
        pass

    def create(self, *args):
        pass

    def update(self, time, delta):
        pass


Phaser.Game.new(dict(

    width=640,
    height=480,
    type=Phaser.CANVAS,
    parent='gamebox',
    scene=[MainScene()()]

    ))

En resumen, hemos creado una Clase Scene para crear nuevas clases que hereden de esta.

Definimos una Clase MainScene que hereda de Scene en la que definimos los métodos init, preload, create y update.

Fíjate en como instanciamos a MainScene:

MainScene()()

Con los primeros paréntesis () se crea un objeto python de la clase MainScene, y con los segundos se devuelve a self.this, que es la Phaser.Scene.

Ya estamos listos para crear el Pong...


DESARROLLO DEL "Juego Pong" CON HERENCIA FAKE



Método init:

Definimos algunas constantes que utilizaremos posteriormente

def init(self, *args):

    self.MIN_X = 0
    self.MAX_X = self.sys.game.config.width

    self.MIN_Y = 0
    self.MAX_Y = self.sys.game.config.height

    self.CENTER_X = self.MAX_X/2
    self.CENTER_Y = self.MAX_Y/2

    self.KEYCODES = Phaser.Input.Keyboard.KeyCodes

En self.sys.game.config hay definidas algunas variables como width y height que utilizaremos para definir los límites del juego y así limitar el espacio sobre el que se mueve la bola.

Almacenamos Phaser.Input.Keyboard.KeyCodes en self.KEYCODES simplemente por comodidad a la hora de definir acciones desencadenadas al pulsar ciertas teclas (el movimiento de las palas).

Puedes inspeccionar los atributos y métodos de la escena en la consola del navegador mediante:

console.log = window.console.log
console.log(self)


Método preload:

Cargamos los sprites del juego, que se encuentran en la carpeta "assets".

def preload(self, *args):

    self.load.path = './assets/'

    for i in 'P1', 'P2', 'BOLA', 'SEPARADOR':
        self.load.image(i, f'{i}.png')

P1 y P2 son los sprites que utilizaremos para dibujar las palas de los jugadores 1 y 2 respectivamente. BOLA y SEPARADOR se describen por si solos.

La función self.load.image permite cargar sprites en la escena. El primer argumento que toma es el nombre del sprite, y el segundo el path de la imágen del sprite.

Aunque las imágenes se encuentran en la carpeta "assets", omitimos a assets al indicar el path de las imágenes como segundo parámetro de self.load.image porque previamente hemos indicado que la carpeta de trabajo es "assets" mediante la función self.load.path.


Método create:

Mientras que en el método preload hemos cargados los sprites del juego, en el método create vamos a añadir estos sprites precargados a la escena. Es en create donde hacemos visibles a nuestros sprites.

Las instrucciones del método create son algo mas complejas que las de los métodos init y preload, de modo que vamos a definir la función poco a poco.


Método crear_pala:

El método self.crear_pala debe devolver un sprite que representa a una pala/player. Su definición es la siguiente:

def crear_pala(self, name, position, keys):

    sprite = self.add.sprite(position[0], position[1], name)

    self.physics.world.enable(sprite)
    sprite.body.immovable = True
    sprite.body.setCollideWorldBounds(True)
    self.physics.add.collider(self.BOLA, sprite, lambda *args: self.BOLA.setVelocityY(Phaser.Math.Between(-120, 120)), None, self)

    up, down = keys

    sprite.KEY_UP = self.input.keyboard.addKey(self.KEYCODES[up])
    sprite.KEY_DOWN = self.input.keyboard.addKey(self.KEYCODES[down])

    return sprite

Analicemos el código de esta función:

sprite = self.add.sprite(position[0], position[1], name)

self.add.sprite nos permite añadir un sprite a la escena. El primer argumento que recibe es su posición en el eje x, el segundo su posición en el eje y, y el tercero es su type (yo lo he llamado name en vez de type porque en python type es una función predefinida).

self.physics.world.enable(sprite)

Hemos aplicado las físicas definidas en la escena sobre el sprite

sprite.body.immovable = True

sprite.body se refiere al cuerpo del sprite, es decir, al objeto que representa su significado físico. En otras palabras, body es el atributo del sprite donde se definen sus propiedades físicas.

Si no establecemos sprite.body.immovable = True, al chocar la bola contra la pala se llevará a la pala por delante. Comprueba lo que sucede si omites esta sentencia o si la estableces en False.

sprite.body.setCollideWorldBounds(True)

Hemos indicado que el sprite debe detectar los límites del mundo, es decir, los límites de la escena/canvas.

self.physics.add.collider(self.BOLA, sprite, lambda *args: self.BOLA.setVelocityY(Phaser.Math.Between(-120, 120)), None, self)

Añadimos un colisionador a las fisicas de la escena. Cuando self.BOLA y sprite colisionen se ejecutará la función:

lambda *args: self.BOLA.setVelocityY(Phaser.Math.Between(-120, 120))

'''
Que podriamos haber definido así:

def colision(*args):
    self.BOLA.setVelocityY(Phaser.Math.Between(-120, 120))
'''

Al colisionar la bola con una pala, a la bola se le asignará una velocidad en el eje Y comprendida entre los valores -120 y 120.

Mientras no colisionen no se ejecutará ninguna función: None.

EL último argumento es self, es decir, la propia escena. Recuerda por un momento que en realidad la escena es self.this, y que gracias a la herencia fake puedes olvidarte de esto.

EL hecho de que el último argumento sea self puede parecer redundante, sin embargo es así porque la función Phaser.Scene.physics.add.collider podría utilizarse para definir una colisión en una escena desde otra escena.

Analicemos el tramo final de la función:

up, down = keys

sprite.KEY_UP = self.input.keyboard.addKey(self.KEYCODES[up])
sprite.KEY_DOWN = self.input.keyboard.addKey(self.KEYCODES[down])

return sprite


Método update:

El método update es el bucle que da vida al juego. Lo primero que vamos a hacer es comprobar la posición de la bola y devolverla al centro si esta ha salido por alguna de las pareces (izquierda o derecha). Posteriormente definiremos lo que debe suceder al pulsar las teclas KEY_UP y KEY_DOWN de los sprites self.P1 y self.P2 (las palas/players).


game.py finalizado

from browser import window
Phaser = window.Phaser


class Scene:

    def __init__(self, active=True):

        self.this = Phaser.Scene.new(self.__class__.__name__)
        self.this.active = active
        self.this.init = self.init
        self.this.preload = self.preload
        self.this.create = self.create
        self.this.update = self.update

    def __getattr__(self, attr):

        if attr not in self.__dict__:
            return self.this[attr]

    def __setattr__(self, attr, value):

        self.__dict__[attr] = value
        self.this[attr] = value

    def __call__(self):
        return self.this





class MainScene(Scene):

    def __init__(self, active=True):
        Scene.__init__(self, active)

    def crear_pala(self, name, position, keys):

        sprite = self.add.sprite(position[0], position[1], name)

        self.physics.world.enable(sprite)
        sprite.body.immovable = True
        sprite.body.setCollideWorldBounds(True)
        self.physics.add.collider(self.BOLA, sprite, lambda *args: self.BOLA.setVelocityY(Phaser.Math.Between(-120, 120)), None, self)

        up, down = keys

        sprite.KEY_UP = self.input.keyboard.addKey(self.KEYCODES[up])
        sprite.KEY_DOWN = self.input.keyboard.addKey(self.KEYCODES[down])

        return sprite

    def init(self, *args):

        self.MIN_X = 0
        self.MAX_X = self.sys.game.config.width

        self.MIN_Y = 0
        self.MAX_Y = self.sys.game.config.height

        self.CENTER_X = self.MAX_X/2
        self.CENTER_Y = self.MAX_Y/2

        self.KEYCODES = Phaser.Input.Keyboard.KeyCodes

    def preload(self, *args):

        self.load.path = './assets/'

        for i in 'P1', 'P2', 'BOLA', 'SEPARADOR':
            self.load.image(i, f'{i}.png')

    def create(self, *args):

        self.add.image(self.CENTER_X, self.CENTER_Y, 'SEPARADOR')

        self.physics.world.setBoundsCollision(False, False, True, True)

        self.BOLA = self.physics.add.image(self.CENTER_X, self.CENTER_Y, 'BOLA')
        self.BOLA.setCollideWorldBounds(True)
        self.BOLA.setBounce(1)
        self.BOLA.setVelocityX(-300)

        self.P1 = self.crear_pala(name='P1', position=(self.MIN_X+30, self.CENTER_Y), keys=('W', 'S'))      
        self.P2 = self.crear_pala(name='P2', position=(self.MAX_X-30, self.CENTER_Y), keys=('UP', 'DOWN'))

    def update(self, time, delta):

        if self.BOLA.x > self.MAX_X or self.BOLA.x < self.MIN_X:
            self.BOLA.setPosition(self.CENTER_X, self.CENTER_Y)

        for pala in self.P1, self.P2:

            if pala.KEY_DOWN.isDown:

                pala.body.setVelocityY(300)

            elif pala.KEY_UP.isDown:

                pala.body.setVelocityY(-300)

            else:

                pala.body.setVelocityY(0)





Phaser.Game.new(dict(

    width=640,
    height=480,
    type=Phaser.CANVAS,
    parent='gamebox',
    scene=[MainScene()()]

    ))