21. Contacto con el modo gráfico

El "modo texto" nos ha permitido practicar ciertos conceptos básicos de un modo sencillo. Pero es "demasiado poco vistoso" para la mayoría de juegos reales. Por eso, vamos a ver cómo mostrar imágenes en pantalla.

Usaremos "de fondo" la biblioteca gráfica SDL, pero la "ocultaremos" para poder esquivar la mayoría de sus detalles internos, y centrarnos en la forma de realizar las tareas habituales, como dibujar imágenes, comprobar colisiones entre ellas, conseguir movimientos animados, evitar parpadeos al dibujar en pantalla, etc.

Así, la apariencia de "Main" será idéntica a la de la versión en modo texto:

public static void Main()
{
 
    InicializarJuego();
 
    while (! juegoTerminado )
    {        
        InicializarPartida();
        MostrarPresentacion();
 
        // ------ Bucle de juego ------
        while( ! partidaTerminada  )
        {
            Dibujar();
            ComprobarTeclas();
            MoverElementos();
            ComprobarColisiones();
            PausaFotograma();
        } // Fin del bucle de juego
    }     // Fin de partida
}

Pero el resultado en pantalla será mucho más vistoso, incluso si queremos conservar una cierta "estética retro":

Juego 5, primer acercamiento

En el fuente, las funciones que tienen que acceder a la consola sí cambiarán, porque ya no usaremos letras sino imágenes, y el manejo de teclado también será ligeramente distinto.

Por ejemplo, para dibujar un enemigo en pantalla, ya no usaremos un "SetCursorPosition" seguido de un "Write", sino una única orden que se encargará de mostrar una imagen en ciertas coordenadas de pantalla:

personaje.imagen.DibujarOculta(personaje.x, personaje.y);

Para escribir textos, usaremos una única función "EscribirTexto" que recibirá como detalles el texto a escribir (sólo un "string", no permitirá el uso de "{0}"), las coordenadas, el color (como 3 cifras R, G, B para detallar la cantidad de rojo, verde y azul) y el tipo de letra (porque podremos usar distintos tipos de letra en un mismo juego):

// Marcador
Hardware.EscribirTextoOculta("Vidas",
  0,0, // Coordenadas
  255, 255, 255, // Colores
  tipoDeLetra);
 
Hardware.EscribirTextoOculta( Convert.ToString(vidas),
  70,0, // Coordenadas
  200, 200, 200, // Colores
  tipoDeLetra);

Estamos hablando de "DibujarOculta" y de "EscribirTextoOculta", porque, para evitar parpadeos, no dibujaremos directamente en la pantalla visible, sino en otra zona de memoria (lo que se conoce como un "doble buffer"), y cuando hayamos preparado todo lo que debe verse en pantalla, entonces volcaremos esa "pantalla oculta" sobre la "pantalla visible". Así, la apariencia de la función "Dibujar" será:

public static void Dibujar()
{
    // -- Dibujar --
    Hardware.BorrarPantallaOculta(0,0,0);
 
    // Marcador
    Hardware.EscribirTextoOculta("Vidas",
             0,0, // Coordenadas
             255, 255, 255, // Colores
            tipoDeLetra);
 
    ...
 
    personaje.imagen.DibujarOculta(
        personaje.x, personaje.y);
 
    // Finalmente, muestro en pantalla
    Hardware.VisualizarOculta();
}
 

(Empezamos borrando la pantalla oculta en color negro, escribimos y dibujamos en ella todo lo que nos interese y finalmente visualizamos esa pantalla oculta)

La forma de comprobar teclas es parecida a la de antes, salvo por que la sintaxis es distinta, y que ahora el personaje no se moverá de uno en uno (que sería de pixel en pixel, demasiado lento), sino con un cierto incremento:

public static void ComprobarTeclas()
{
    // -- Leer teclas y calcular nueva posición --
    if (Hardware.TeclaPulsada(Hardware.TECLA_ESC) )
        partidaTerminada = true;
 
    if (Hardware.TeclaPulsada(Hardware.TECLA_DER) )
        personaje.x += personaje.incrX;
    if (Hardware.TeclaPulsada(Hardware.TECLA_IZQ) )
        personaje.x -= personaje.incrX;
    if (Hardware.TeclaPulsada(Hardware.TECLA_ARR) )
        personaje.y -= personaje.incrY;
    if (Hardware.TeclaPulsada(Hardware.TECLA_ABA) )
        personaje.y += personaje.incrY;
}

Las funciones de MoverElementos y ComprobarColisiones no cambian, aunque la segunda sí debería hacerlo, porque es difícil que nuestro personaje y un enemigo coincidan exactamente en la misma coordenada X de pantalla y en la misma Y, debería bastar con que se solaparan, pero eso lo mejoraremos en la siguiente entrega.

En "InicializarPartida" no habrá grandes diferencias: apenas que las coordenadas ahora de van de 0 a 79 en horizontal y de 0 a 24 en vertical, sino de 0 a 799 y de 0 a 599 (si usamos el modo de pantalla de 800x600 puntos), y que deberemos cargar las imágenes en vez de asignar letras (realmente no haría falta cargar las imágenes para cada nueva partida; lo mejoraremos más adelante):

public static void InicializarPartida()
{
    // En cada partida, hay que reiniciar ciertas variables
    vidas = 3;
    puntos = 0;
    partidaTerminada = false;
 
    personaje.xInicial = 400;
    personaje.yInicial = 300;
    personaje.incrX = 10;
    personaje.incrY = 10;
    personaje.imagen = new Imagen("personaje.png");
    ...
 

Y en "InicializarJuego" el único cambio es que deberemos "entrar a modo gráfico", por ejemplo a 800x600, con 24 bits de color (16 millones de colores), y detallar también si queremos que el juego esté en ventana o en pantalla completa:

public static void InicializarJuego()
{
    // Entrar a modo grafico 800x600
    bool pantallaCompleta = false;
    Hardware.Inicializar(800, 600, 24, pantallaCompleta);
 
    // Resto de inicializacion
    tipoDeLetra = new Fuente("FreeSansBold.ttf", 18);
    juegoTerminado = false;
    numPremios = 10;
    ...
 

El fuente que une todas estas ideas podría ser así:

// Primer mini-esqueleto de juego en modo gráfico
// Versión "a"
 
using System;
using System.Threading; // Para Thread.Sleep
 
public class Juego05a
{
    struct ElemGrafico {
        public int x;
        public int y;
        public int xInicial;
        public int yInicial;
        public int incrX;
        public int incrY;
        public Imagen imagen;
        public bool visible;
    }
 
    static ElemGrafico personaje;
    static Fuente tipoDeLetra;
 
    static int numPremios, numEnemigos, numObstaculos;
 
    static ElemGrafico[] obstaculos;
    static ElemGrafico[] enemigos;
    static ElemGrafico[] premios;
 
    static bool juegoTerminado;
    static int vidas;
    static int puntos;
    static bool partidaTerminada;
    static Random generador;
 
 
    public static void InicializarJuego()
    {
        // Entrar a modo grafico 800x600
        bool pantallaCompleta = false;
        Hardware.Inicializar(800, 600, 24, pantallaCompleta);
 
        // Resto de inicializacion
        tipoDeLetra = new Fuente("FreeSansBold.ttf", 18);
        juegoTerminado = false;
        numPremios = 10;
        numEnemigos = 10;
        numObstaculos = 20;
        obstaculos = new ElemGrafico[numObstaculos];
        enemigos = new ElemGrafico[numEnemigos];
        premios = new ElemGrafico[numPremios];
        generador = new Random();
    }
 
 
    public static void InicializarPartida()
    {
        // En cada partida, hay que reiniciar ciertas variables
        vidas = 3;
        puntos = 0;
        partidaTerminada = false;
 
        personaje.xInicial = 400;
        personaje.yInicial = 300;
        personaje.x = personaje.xInicial;
        personaje.y = personaje.yInicial;
        personaje.incrX = 10;
        personaje.incrY = 10;
        personaje.imagen = new Imagen("personaje.png");
 
        // Genero las posiciones de los elementos al azar
        for (int i=0; i<numObstaculos; i++)  // Obstaculos
        {
            obstaculos[i].x = generador.Next(50,700);
            obstaculos[i].y = generador.Next(30,550);
            obstaculos[i].imagen = new Imagen("obstaculo.png");
        }
 
        for (int i=0; i<numEnemigos; i++)  // Enemigos
        {
            enemigos[i].x = generador.Next(50,700);
            enemigos[i].y = generador.Next(30,550);
            enemigos[i].incrX = 5;
            enemigos[i].imagen = new Imagen("enemigo.png");
        }
 
        for (int i=0; i<numPremios; i++)  // Premios
        {
            premios[i].x = generador.Next(50,700);
            premios[i].y = generador.Next(30,550);
            premios[i].imagen = new Imagen("premio.png");
            premios[i].visible = true;
        }
    }
 
 
    public static void MostrarPresentacion()
    {
        // ---- Pantalla de presentación --
        Hardware.BorrarPantallaOculta(0,0,0);
 
        // Marcador
        Hardware.EscribirTextoOculta("Jueguecillo",
                 340,200, // Coordenadas
                 255, 255, 255, // Colores
                tipoDeLetra);
 
        Hardware.EscribirTextoOculta("Escoja una opción:",
                 310,300, // Coordenadas
                 200, 200, 200, // Colores
                tipoDeLetra);
 
        Hardware.EscribirTextoOculta("1.- Jugar una partida",
                 150,390, // Coordenadas
                 200, 200, 200, // Colores
                tipoDeLetra);
 
        Hardware.EscribirTextoOculta("0.- Salir",
                 150,430, // Coordenadas
                 200, 200, 200, // Colores
                tipoDeLetra);
        Hardware.VisualizarOculta();
 
        bool finPresentacion = false;
        do
        {
            Hardware.Pausa( 20 );
            if (Hardware.TeclaPulsada(Hardware.TECLA_1) )
                finPresentacion = true;
 
            if (Hardware.TeclaPulsada(Hardware.TECLA_0) )
            {
                finPresentacion = true;
                partidaTerminada = true;
                juegoTerminado = true;
            }
        } while (! finPresentacion );        
    }
 
    public static void Dibujar()
    {
        // -- Dibujar --
        Hardware.BorrarPantallaOculta(0,0,0);
 
        // Marcador
        Hardware.EscribirTextoOculta("Vidas        Puntos",
                 0,0, // Coordenadas
                 255, 255, 255, // Colores
                tipoDeLetra);
 
        Hardware.EscribirTextoOculta( Convert.ToString(vidas),
                 70,0, // Coordenadas
                 200, 200, 200, // Colores
                tipoDeLetra);
 
        Hardware.EscribirTextoOculta( Convert.ToString(puntos),
                 190,0, // Coordenadas
                 200, 200, 200, // Colores
                tipoDeLetra);
 
        for (int i=0; i<numObstaculos; i++)  // Obstáculos
        {
            obstaculos[i].imagen.DibujarOculta(
                (int) obstaculos[i].x, (int) obstaculos[i].y);
        }
 
        for (int i=0; i<numEnemigos; i++)  // Enemigos
        {
            enemigos[i].imagen.DibujarOculta(
                (int) enemigos[i].x, (int) enemigos[i].y);
        }
 
        for (int i=0; i<numPremios; i++)  // Premios
        {
            if (premios[i].visible)
            {
                premios[i].imagen.DibujarOculta(
                    premios[i].x, premios[i].y);
            }
        }
 
        personaje.imagen.DibujarOculta(
            personaje.x, personaje.y);
 
        // Finalmente, muestro en pantalla
        Hardware.VisualizarOculta();
    }
 
 
    public static void ComprobarTeclas()
    {
        // -- Leer teclas y calcular nueva posición --
        if (Hardware.TeclaPulsada(Hardware.TECLA_ESC) )
            partidaTerminada = true;
 
        if (Hardware.TeclaPulsada(Hardware.TECLA_DER) )
            personaje.x += personaje.incrX;
        if (Hardware.TeclaPulsada(Hardware.TECLA_IZQ) )
            personaje.x -= personaje.incrX;
        if (Hardware.TeclaPulsada(Hardware.TECLA_ARR) )
            personaje.y -= personaje.incrY;
        if (Hardware.TeclaPulsada(Hardware.TECLA_ABA) )
            personaje.y += personaje.incrY;
    }
 
 
    public static void MoverElementos()
    {
        // -- Mover enemigos, entorno --
        for (int i=0; i<numEnemigos; i++)  // Enemigos
        {
            enemigos[i].x = enemigos[i].x + enemigos[i].incrX;
            if (( (int) enemigos[i].x <= 50)
                    || ( (int) enemigos[i].x >= 700))
                enemigos[i].incrX = - enemigos[i].incrX;
        }
    }
 
 
    public static void ComprobarColisiones()
    {
        // -- Colisiones, perder vidas, etc --
        for (int i=0; i<numObstaculos; i++)  // Obstáculos
        {
            if ((obstaculos[i].x == personaje.x)
                && (obstaculos[i].y == personaje.y))
            {
                vidas --;
                if (vidas == 0)
                    partidaTerminada=true;
                personaje.x = personaje.xInicial;
                personaje.y = personaje.yInicial;
            }
        }
 
        for (int i=0; i<numPremios; i++)  // Obstáculos
        {
            if ((premios[i].x == personaje.x)
                && (premios[i].y == personaje.y)
                && premios[i].visible )
            {
                puntos += 10;
                premios[i].visible = false;
            }
        }
 
        for (int i=0; i<numEnemigos; i++)  // Enemigos
        {
            if (( (int) enemigos[i].x == personaje.x)
                    && ( (int) enemigos[i].y == personaje.y))
            {
                vidas --;
                if (vidas == 0)
                    partidaTerminada=true;
                personaje.x = personaje.xInicial;
                personaje.y = personaje.yInicial;
            }
        }
 
    }
 
 
    public static void PausaFotograma()
    {
        // -- Pausa hasta el siguiente "fotograma" del juego --
        Hardware.Pausa( 20 );
    }
 
 
    public static void Main()
    {
 
        InicializarJuego();
 
        while (! juegoTerminado )
        {
            InicializarPartida();
            MostrarPresentacion();
 
            // ------ Bucle de juego ------
            while( ! partidaTerminada  )
            {
                Dibujar();
                ComprobarTeclas();
                MoverElementos();
                ComprobarColisiones();
                PausaFotograma();
            } // Fin del bucle de juego
        }     // Fin de partida
    }         // Fin de Main
}
 

Este programa no está formado sólo por ese fichero, sino que también existen otros ficheros que detallan como cargar una Imagen, o un tipo de letra (Fuente), o cómo acceder al Hardware (a la pantalla gráfica, el teclado, un joystick o gamepad). Además, son necesarios varios ficheros DLL. Por eso, si quieres probar a modificar este juego para tu propia versión, tendrás que descargar uno de los siguientes ficheros:

  • Un proyecto para Visual Studio 2010, que incluye todo lo necesario para compilar y probar el juego. Basta con la versión Express, que se puede descargar de forma gratuita desde la página de Microsoft.
  • Una versión para compilar desde línea de comandos, por si no tienes Visual Studio 2010 (pero necesitas tener al menos la plataforma "punto net" en su versión 2, y hacer doble clic en "CompilaDotNet" para crear el ejecutable.
  • Una versión alternativa para MonoDevelop 2.4, que quizá te sirva si usas Linux o MacOS X (sí funciona en un Linux Mint 11 32 bits con SDL 1.2.x, quizá no funcione con versiones más modernas de SDL o con sistemas de 64 bits). Es posible que necesites instalar la versión "de desarrollo" de SDL: libsdl1.2-dev (o similar) y sus auxiliares correspondientes: libsdl-image1.2-dev para visualizar imágenes, libsdl-ttf2.0-dev para tipos de letra TrueType y (dentro de no mucho tiempo) libsdl-mixer1.2-dev para reproducir sonidos MP3 y MID.

Ejercicio propuesto: Incluye una imagen de fondo en la pantalla de presentación. Piensa cómo hacer que las colisiones sean correctas (si no lo descubres, no te preocupes: lo veremos en un apartado cercano).