top of page
Buscar

4. Manejo de Imágenes

  • Foto del escritor: Fernando Sansberro
    Fernando Sansberro
  • 25 oct 2021
  • 14 Min. de lectura

Actualizado: 25 dic 2021

Hasta ahora hemos utilizado cuadrados en lugar de imágenes, pero esto se puede ver como aburrido. Un videojuego siempre debe tener imágenes y de hecho debe tener un excelente arte. En este capítulo comenzaremos a incorporar imágenes a nuestro juego.


Nota: Cuando comenzamos a desarrollar un videojuego, no siempre tendremos el arte disponible, generalmente porque el artista está en proceso de hacerlo. El programador debe utilizar gráficos temporales para poder programar el juego, mientras no tiene los gráficos finales. A los gráficos o arte en general que es temporal, se le denomina placeholder, y es una práctica habitual que el programador desarrolle el juego con placeholders hasta que el arte final sea integrado en el juego.


Para manejar imágenes, utilizaremos el módulo Image de Pygame. Comenzaremos viendo qué son las superficies y cómo se crean, y luego veremos el manejo de colores y el manejo de las imágenes.



Usando Imágenes


Las imágenes son parte vital de un videojuego. Cuando estamos jugando un juego 2D, vemos imágenes representando al fondo (el escenario), al jugador, a los enemigos, a la interfaz del juego, etc. Si se tratara de un videojuego 3D, las imágenes son utilizadas, por ejemplo, en las texturas de los objetos para crear una escena.


Para crear las imágenes de un videojuego se utiliza cualquier software para la creación de gráficos. La imagen es almacenada en un archivo, que cargaremos en el programa y mostraremos en la pantalla.


Una imagen es almacenada en un archivo o en memoria, como una matriz de píxeles, cada uno de un color determinado. La forma en la que se guarda la información de los píxeles de la imagen se denomina formato de la imagen. Algunas imágenes pueden tener más información que otras para almacenar un píxel. Por ejemplo, una imagen puede tener un formato RGB para cada píxel, mientras que otra imagen puede tener un formato RGBA (Red, Green, Blue, Alpha), añadiendo un cuarto componente para la transparencia (alpha).


Durante la historia, se han desarrollado muchos formatos para guardar imágenes, cada formato con sus pros y sus contras. En el desarrollo de videojuegos, los más usados son el formato JPEG y PNG. Ambos formatos se pueden utilizar en la inmensa mayoría de los software de edición de gráficos.


- PNG (Portable Network Graphics): Los archivos PNG (tienen extensión .png) son los más utilizados en videojuegos porque tienen muy buena compresión (la imagen tiene un menor tamaño en bytes). El formato PNG puede tener transparencia y esto es muy usado en los juegos. La única desventaja es que un archivo PNG suele ser más grande que un archivo JPG.


- JPG (Joint Photographic Expert Group): Los archivos JPG (tienen extensión .jpg o .jpeg) fueron diseñados para almacenar imágenes fotográficas. Comprimen mucho más que los archivos PNG, por lo cual son de bastante menos tamaño, pero con la desventaja de que las imágenes se ven un poco peor de calidad, a causa de la compresión usada (generalmente la diferencia de calidad es muy sutil).


Figura 4-1: Imágenes que usaremos en nuestro juego.



Además de los formatos PNG y JPG, Pygame soporta otros formatos como GIF, BMP, PCX, TGA, TIFF etc.

En nuestros juegos, seguramente usemos imágenes en formato JPG para los fondos y para las imágenes que sean muy grandes. y utilizaremos imágenes en formato PNG para todos los objetos del juego como el personaje, los enemigos, etc. (generalmente denominados los sprites del juego).


Nota: El proyecto “Hacete tu Videojuego” del cual es parte este libro, contiene gráficos que puedes utilizar en tu propio juego. También es posible descargar arte de uso libre (open source) de los sitios listados en la carpeta que contiene estos assets y que viene con los materiales del libro.


Crear una Superficie


Una superficie es un espacio en la memoria de la computadora conteniendo una imagen. Como todo lo que está en la memoria, una imagen es un área de bytes, conteniendo en este caso la información de los componentes de cada píxel de la imagen, un byte tras otro.


Podemos crear una superficie para luego dibujar en ella, o podemos cargar un archivo de imagen desde el disco o drive. Cuando cargamos una imagen, se crea una superficie en memoria conteniendo la información de la imagen que se lee desde el disco.

Como ya hemos visto cuando hicimos la clase CCuadrado, podemos crear una superficie (o sea, una imagen) haciendo lo siguiente:


self.mImg = pygame.Surface((aWidth, aHeight))
self.mImg = self.mImg.convert()
self.mImg.fill(aColor)

Lo que hace la función pygame.Surface() es crear una superficie con un tamaño dado por el ancho y el alto que le pasamos como parámetro en una tupla. Sin otros parámetros aparte del ancho y alto, esta función creará una superficie con el mismo formato del display, que generalmente esto es lo que queremos hacer, dado que es mucho más rápido de dibujar las imágenes que están en el mismo formato que el display.


Por ejemplo, el siguiente código crea una superficie de 256 x 256 píxeles (debemos pasarle como parámetro la tupla con el ancho y el alto):


imgTemp = pygame.Surface((256, 256))
imgTemp = imgTemp.convert()

Es posible crear superficies de cualquier tamaño, con tal que haya espacio suficiente en la memoria de la computadora para almacenarla. La función convert() lo que hace es convertir el formato de la imagen creada al formato que tenga el display. Siempre haremos esto porque es más rápido dibujar una imagen en otra si ambas tienen el mismo formato. Si no lo tuvieran, Pygame tiene que hacer la conversión cada vez que va a dibujar. Más adelante veremos más detalles sobre cómo convertir el formato de una imagen.


Con la función fill() se llena la superficie con un color. De esta forma hemos dibujado antes un cuadrado amarillo o rojo. A continuación, veremos cómo indicar los colores.



Los Colores en Pygame


Cuando creamos el cuadrado en los ejemplos anteriores, le pasamos una tupla con valores RGB a la función fill() para que llene la imagen con ese color. Ahora veremos cómo funcionan los colores en formato RGB.


Cuando tenemos que indicar un color, pasamos una tupla con tres valores enteros, uno para cada componente del color. Los tres componentes de un color son el rojo, el verde y el azul (RGB: red, green, blue). Cada uno de estos números puede estar entre 0 y 255, donde 0 significa ausencia del componente y 255 significa el máximo de ese componente (su máxima intensidad).


El color formado se da por la intensidad del componente rojo, el componente verde y el componente azul combinados. Cuanto más de un componente o de otro tenga la tupla, más rojo, verde o azul será el color (o la combinación de ellos).


En la siguiente tabla se ven los colores típicos:



Figura 4-2: Tabla con los colores básicos.



Cuando vamos a establecer un color, debemos probar cambiando los valores R, G y B hasta conseguir el color deseado. Requiere práctica conseguir el color que deseamos y lo mejor es utilizar cualquier programa de dibujo que muestre los valores R, G y B mientras los vemos en un selector de colores. Una vez que tenemos los valores de los tres componentes, los copiamos y los pegamos en el código.



Figura 4-3: Selector de colores de GIMP.



En la figura 4-2 se muestra el selector de colores del programa Gimp, en el cual se muestra el color seleccionado actualmente y los tres valores de los componentes RGB (indicados en el dibujo). Esos tres valores son los que utilizaremos en el programa para indicar el color deseado.


Cargar una Imagen


Generalmente, cargaremos imágenes ya hechas por el artista y utilizaremos funciones de Pygame para cargar estas imágenes. Para mostrar imágenes en la pantalla, lo primero que haremos es cargarlas a la memoria desde un archivo PNG o JPG, y luego convertirlas al formato de la pantalla. Una vez hecho eso, las imágenes ya se podrán dibujar en la pantalla con la función blit().


Cuando estamos desarrollando un juego, el artista nos da las imágenes de los elementos del juego y el programador se encarga de ponerlas en el proyecto y programar la lógica para que las cosas sucedan. Una vez que tenemos la imagen que nos entrega el artista, la colocamos en la carpeta (folder) correspondiente del proyecto para que el programa pueda acceder a ella.


Lo que haremos ahora es poner como fondo para nuestro juego una imagen del espacio. Mira el ejemplo que se encuentra en la carpeta capitulo_04\001_cargar_imagen_de_fondo. Al correr este ejemplo vemos que aparece de fondo una imagen de fondo con la tierra y el espacio.



Figura 4-4: El juego con la imagen de fondo.



En la carpeta del ejemplo hay una imagen de nombre space_640x360.jpg que es la que el programa carga y muestra en la pantalla. El programa utiliza las siguientes sentencias para cargar la imagen:


# Cargar la imagen del fondo. La imagen es de 640 x 360.
imgSpace = pygame.image.load("space_640x360.jpg")
imgSpace = imgSpace.convert()

La función pygame.image.load() carga una imagen (que puede estar en cualquiera de los formatos comunes: PNG, JPG, GIF o BMP) y nos retorna una superficie con la imagen, cosa que guardamos en la variable correspondiente (denominada imgSpace). A la función le pasamos como parámetro la ruta donde se encuentra la imagen y el nombre del archivo. Si solamente pasamos el nombre de la imagen, ésta será buscada en la carpeta del programa.


La función pygame.image.load() lo que hace es cargar la imagen que está contenida en el archivo space_640x360jpg. Como no le indicamos la ruta del archivo (solamente ponemos el nombre del archivo), lo toma desde la misma carpeta en la cual está el programa. Más adelante haremos carpetas para guardar imágenes, sonidos, niveles, y otros tipos de assets, pero por ahora como tenemos pocos archivos los pondremos todos juntos en la misma carpeta.


La segunda línea: imgSpace = imgSpace.convert() lo que hace es convertir la imagen al mismo formato que tiene la pantalla. Esto se debe hacer porque los archivos JPG, GIF o PNG tienen compresión y Pygame necesita utilizarlos sin compresión cuando los va a mostrar en la pantalla. Si no convertimos la imagen cuando la cargamos, estto lo tendría que hacer Pygame cada vez que se va a dibujar, lo cual haría que todo funcione más lento. Por esta razón es que siempre luego de cargar o crear una imagen, se debe llamar a la función convert().


Ahora, luego que tenemos cargada la imagen en memoria y convertida al formato de la pantalla, la podemos dibujar en cualquier momento.


Nota: La variable imgSpace es una variable que apunta al área de memoria de la máquina en donde se encuentra la imagen cargada. Internamente esa variable es una referencia a la imagen, o en otros lenguajes, como en el lenguaje C, se dice que es un puntero a la imagen. Esto significa que cada vez que usemos esta variable nos estamos refiriendo al área a la que apunta. En este caso, es una imagen.


Cuando cargamos la imagen del fondo, lo hacemos sin transparencia. Las imágenes que usamos de fondo no tienen transparencia, dado que no hay nada detrás de ellas.


Dibujar una Imagen


Una vez que tenemos la imagen cargada en memoria (y una variable representando o referenciando a la imagen), usamos la función blit() para mostrar la imagen. Esta función se utiliza aplicada a la imagen en donde vamos a dibujar (la imagen de destino).


# Dibujar la imagen cargada en la imagen de background.
imgBackground.blit(imgSpace, (0, 0))

En este caso, la imagen de destino es la imagen de fondo. Por eso usamos imgBackground.blit(). La función se aplica a la imagen en donde queremos dibujar. Se le pasa como parámetro la imagen a dibujar y la coordenada donde se ubica la esquina superior izquierda. Como la imagen a dibujar tiene el mismo tamaño que la pantalla, dibujamos en la esquina superior izquierda de la pantalla, cuya coordenada es la tupla (0, 0).


Blit significa binary block transfer y significa copiar píxel por píxel una imagen a una superficie. Es una técnica para copiar zonas rectangulares de una parte a otra, generalmente entre superficies o de una superficie a la pantalla. Esta operación viene incluída en todas las tarjetas de video. La acción de copiar una imagen en una nueva ubicación se denomina blitting.


Entonces, para dibujar una imagen (origen) en otra (destino), utilizamos la función blit(), que tiene la siguiente sintaxis: (imgDestino.blit(imgOrigen, (x, y)). La figura 4-5 muestra gráficamente cómo se copia píxel a píxel una imagen en otra.



Figura 4-5: Blit de una imagen en otra.



Nota: Los ejemplos fueron escritos para funcionar en modo ventana en una resolución de 640 x 360 píxeles. Esto se ha definido así por dos motivos. Primero porque se utiliza la técnica pixel art y en esta técnica, las resoluciones son bajas. Segundo, porque es una resolución con formato de pantalla wide, que se podrá adaptar bien a los monitores modernos.


Nota: En los ejemplos recuerda que se puede poner en modo fullscreen (pantalla completa) pulsando la tecla [F]. Si en modo fullscreen la pantalla no se ve completa (en algunas tarjetas de video o monitores), no importa, en este punto debemos concentrarnos en aprender cómo programar videojuegos. Más adelante haremos que el juego funcione en todas las resoluciones posibles.


Lo siguiente que haremos ahora que tenemos el fondo del juego es agregar naves enemigas. Las naves del juego, como tienen partes transparentes, las debemos cargar con transparencia.


En realidad lo que haremos ahora será cambiar los cuadrados por naves enemigas. Para esto haremos otra clase denominada CNave, que será como la clase CCuadrado, pero en lugar de crear una superficie y llenarla de un color, ahora cargará una imagen. Esto es lo que haremos a continuación.


Cargar Imágenes con Transparencia


Una imagen con transparencia se usa cuando los objetos tienen partes transparentes, de forma tal que se pueden dibujar sobre otras imágenes de fondo y se ve a través de partes de ella. Por ejemplo, cuando dibujamos una de las naves enemigas sobre el fondo del juego, si no se usa una imagen con transparencia para la nave, veríamos un feo rectángulo alrededor de ella. Si cargamos una imagen y ocurre esto, significa que no hemos cargado la imagen correctamente, o bien que la imagen no tiene transparencia.



Figura 4-6: Las naves del juego son imágenes con transparencia.

.


Nota: Si queremos usar una imagen con transparencia en el juego, debemos asegurarnos que la imagen a cargar ya tenga transparencia. Si este no fuera el caso, hay que utilizar un software gráfico como GIMP (www.gimp.org) y agregar el canal alpha (canal de transparencia) a la imagen.


Ahora lo que haremos es cambiar los cuadrados que tenemos en el ejemplo anterior, por naves espaciales. Para eso, ponemos los dos gráficos de las naves en la carpeta del proyecto. Los archivos tienen como nombres: grey_ufo.png y yellow_ufo.png.


Lo que haremos ahora es hacer una clase denominada CNave que será similar a la clase CCuadrado, salvo que en lugar de crear una superficie y llenarla de un color, va a cargar la imagen de la nave.


Mira el código del ejemplo que se encuentra en la carpeta capitulo_04\002_clase_nave, y ejecútalo. Verás dos naves enemigas moviéndose de igual forma que lo hacían los cuadrados.




Figura 4-7: El juego ahora tiene imágenes para el fondo y para las naves.



La clase CCuadrado la copiamos y la nombramos como clase CNave. El código de la nave enemiga hace lo mismo que hacían los cuadrados. La diferencia está en el constructor, que carga la imagen de la nave (es una imagen con transparencia). El constructor de la clase CNave se muestra a continuación (el resto del código es igual que lo que había en la clase CCuadrado):


...

# ----------------------------------------------------------------
# Constructor.
# Parámetros:
# aImgFile: Nombre de la imagen a cargar.
# ----------------------------------------------------------------
def __init__(self, aImgFile):

   # Coordenadas de la nave.
   self.mX = 0
   self.mY = 0

   # Imagen (superficie).
   self.mImg = None

   # Variables para controlar los bordes.
   self.mMinX = -float("inf")
   self.mMaxX = float("inf")
   self.mMinY = -float("inf")
   self.mMaxY = float("inf")

   # Cargar la imagen.
   self.mImg = pygame.image.load(aImgFile)
   self.mImg = self.mImg.convert_alpha()

   # Guardar el ancho y el alto.
   self.mWidth = self.mImg.get_width()
   self.mHeight = self.mImg.get_height()

...

En el constructor de la clase CNave se recibe como parámetro el nombre del archivo que contiene la imagen de la nave y que se carga en memoria con la función pygame.image.load(). Luego se convierte la imagen al formato de la pantalla usando la función convert_alpha(), en lugar de usar convert() como hicimos antes. Esto hay que hacerlo así porque las imágenes de las naves tienen transparencia.


Si pruebas usando la función convert() en lugar de convert_alpha() verás que quedan las áreas que son transparentes como blancas y se ve el rectángulo alrededor de la nave. Cuando se carga una imagen que tiene transparencia, siempre hay que convertirla al formato de la pantalla usando convert_alpha() para que la transparencia funcione.


Por último, se guarda el ancho y el alto de la imagen utilizando las funciones get_width() y get_height() de la imagen. Ya no es necesario pasarle como parámetro el ancho y el alto como hacíamos con los cuadrados, porque ahora, el ancho y el alto se toman de la imagen misma.


En el programa principal (main.py), lo que hacemos es crear dos instancias de la clase CNave, en lugar de instancias de la clase CCuadrado como hacíamos en ejemplos anteriores. Para eso utilizamos dos variables, una para cada nave, que denominamos n1 y n2.

El código del programa principal es igual al del ejemplo anterior, salvo por las siguientes sentencias que se encargan de crear (instanciar) las dos naves enemigas:


# Importar la clase CNave.
from CNave import *

...

# Definir ancho y alto de la pantalla
SCREEN_WIDTH = 640
SCREEN_HEIGHT = 360
RESOLUTION = (SCREEN_WIDTH, SCREEN_HEIGHT)
# Control del modo ventana o fullscreen.
isFullscreen = False

...

# Ancho y alto de la imagen de las naves.
NAVE_WIDTH = 60
NAVE_HEIGHT = 27

# Crear las naves: se le pasa como parámetro la imagen de la nave.
n1 = CNave("grey_ufo.png")
n2 = CNave("yellow_ufo.png")

# Colocar las naves en su posición inicial.
n1.setXY(0, 180)
n2.setXY(SCREEN_WIDTH - NAVE_WIDTH, 212)

# Marcar los límites del mundo.
n1.setBounds(-NAVE_WIDTH, -NAVE_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT)
n2.setBounds(-NAVE_WIDTH, -NAVE_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT)

...

Al principio del programa se encuentra la sentencia from CNave import *. Recuerda que para usar una clase siempre debemos importarla. De lo contrario nos dará un error al compilar.


Las constantes NAVE_WIDTH y NAVE_HEIGHT contienen el ancho y alto de las imágenes de las naves. Estos valores los conocemos porque son el ancho y alto de las imágenes que el artista ha creado para nosotros y no cambian en el juego (por eso le decimos constantes y se escriben sus nombres en mayúsculas). Utilizaremos estos valores para calcular los límites por donde se pueden mover las naves.


La sentencia n1 = CNave("grey_ufo.png") lo que hace es crear un objeto de la clase CNave (a lo que se le llama instanciar un objeto). Esta función, que tiene el mismo nombre que la clase, se le llama función constructora, y lo que hace es crear un objeto de esa clase (en este caso, crea un objeto de la clase CNave y lo asigna a la variable o referencia n1). El constructor de la nave recibe como parámetro el nombre de la imagen a cargar. De esta forma se crean dos naves en el programa.


Por último, posicionamos a las dos naves en los bordes de la pantalla y le damos los valores límites para su movimiento. Para esto, utilizamos las constantes SCREEN_WIDTH y SCREEN_HEIGHT que tienen el ancho y alto de la pantalla respectivamente, y las constantes NAVE_WIDTH y NAVE_HEIGHT que tienen el ancho y alto de las naves. Por ejemplo, para que la segunda nave quede recostada sobre la derecha de la pantalla, la coordenada x que debemos ponerle es SCREEN_WIDTH - NAVE_WIDTH.


Nota: Es una práctica profesional el usar constantes y nunca usar valores directamente en el código. De esta forma, el programa continúa funcionando correctamente si luego cambia el ancho y alto de la imagen o de la pantalla. En tal caso solamente cambiamos los valores de las constantes. Imagina si cambia el ancho y alto de la pantalla, y hemos puesto por todas partes los números 640 y 360 en lugar de SCREEN_WIDTH y SCREEN_HEIGHT, deberíamos cambiar todos los números. Utilizando constantes el programa funciona igual si queremos una pantalla diferente, solamente cambiando las constantes.


Finalmente, en el game loop hemos cambiado las variables de los cuadrados (c1 y c2) por las de las naves (n1 y n2). Como siempre, invocamos a las funciones update() y render() en el game loop.


...

# Mover las naves y controlar los bordes.
n1.update(2, 1)
n2.update(-2, -1)

# Dibujar el fondo.
screen.blit(imgBackground, (0, 0))

# Dibujar las naves en la nueva posición.
n1.render(screen)
n2.render(screen)

...

Establecer Valores por Defecto


Es muy importante establecer valores por defecto que tengan sentido. Por ejemplo, para establecer los límites por donde se puede mover la nave, hemos puesto valores muy grandes (-infinito hasta +infinito) donde antes estaba en cero.


self.mMinX = -float(“inf”)
self.mMaxX = float(“inf”)
self.mMinY = -float(“inf”)
self.mMaxY = float(“inf”)

Si tuviéramos los bordes en cero, y nos olvidamos de llamar a setBounds() para establecer límites de movimiento, los límites por defecto serían cero, y por las sentencias if que hay al chequear bordes, siempre dejarían a la nave en la posición (0,0), con lo cual no se movería.


Entonces, el comportamiento por defecto debería ser que si no llamamos a setBounds() para establecer límites, entonces la nave no tiene límites, o lo que es lo mismo, los límites son desde -infinito a +infinito. Este número, “infinito”, es el número más grande que se puede representar, y en Python es float(“inf”).


Siempre que establecemos valores por defectos, debemos establecer valores que tengan sentido y no depender de que llamemos a funciones para establecer valores que funcionen. Siempre hay que definir valores que por defecto permitan trabajar con el objeto en forma sencilla (en este caso, por defecto no hay límites de movimiento de la nave).


Con esto, terminamos este capítulo. A continuación veremos cómo es la estructura de un videojuego.


 
 
 

Comments


bottom of page