Como hacer un motor de Tiles – Parte 1


Desde hace rato tenía pensado hacer este tutorial, y pues voy a empezar mostrando lo básico y poco a poco lo iré mejorando.

En este tutorial, mostraré como crear un motor capaz de crear mapas aleatorios con una serie de Tiles, y poder mover la cámara al estilo de los juegos RPG y poder hacer Zoom.

Definiciones:

Tile (o baldosa en español) : son pequeñas partes de una textura que poniéndolas de una forma adecuada puede formar paisajes y fondos para los juegos, dando la sensación de que es una sola pieza. La técnica de Tiles es muy usada en juegos, ya que permite almacenar mapas gigantescos con pocos requerimientos.

TileSet: Conjunto de Tiles, es una textura que tiene los tiles que vamos a utilizar en el juego, son utilizados para no tener que separar cada textura de los tiles en archivos diferentes, sino dejarlos en una sola textura.

TileMap: Es el mapa donde se guarda la información de cada Tile, la mayoría de veces es una matriz de 2X2, donde en cada posición se guarda un número, este número es el tipo de Tile que se va a dibujar.

TileLayer: Un mapa puede tener una o varias capas, una capa (Layer) puede representar la base de las texturas, por ejemplo un prado, habrá una segunda capa donde tendremos solo árboles, y en otra capa podemos tener objetos móviles.

Cámara: Con la cámara podemos tener un control de la visión del mapa, ya que si el mapa es muy grande y en la pantalla no se puede visualizar todo el mapa, podemos con la cámara movernos a través del mapa.

Ahora a codificar

Podemos crear un proyecto nuevo o continuar con un proyecto donde tengamos clases utiles, en mi caso voy a continuar con el proyecto del administrador de ventanas.

Creamos una clase llamada Tile, dicha clase solo tendrá una propiedad llamada Tipo, que será un entero y se le asignará el tipo de Tile que se dibujará:

public class Tile
{
  public Int32 Tipo { get; set; }

  public Tile(Int32 tipo)
  {
     Tipo = tipo;
  }
}

Ahora vamos a crear la clase que manejará las texturas de los Tiles, la clase tendrá un diccionario donde se guardará cada rectángulo que representa un Tile, con su respectiva llave, un método para adicionar los rectángulos y el método para obtener el rectángulo dependiendo de la llave:

public class TileSheet
{
  private Texture2D textura;
  private Dictionary<int, Rectangle> listaRectangulos;

  public TileSheet(Texture2D textura)
  {
     this.textura = textura;
     listaRectangulos = new Dictionary<int, Rectangle>();
  }

  public void adicionarRectangulo(Int32 clave, Rectangle rect)
  {
     listaRectangulos.Add(clave, rect);
  }

  public Texture2D Textura
  {
     get
     {
       return textura;
     }
  }

  // se usan variables por referencia para evitar copias en la pila
  public void obtenerRectangulo(ref Tile i, out Rectangle rect)
  {
     rect = listaRectangulos[i.Tipo];
  }
}

Como ven, el método de obtenerRectangulo, usa como parámetro una variable por referencia, para agilizar las cosas y evitar copias en la pila.

La próxima clase que vamos a tener es la de las capas, está clase es la que va a tener la matriz de 2×2, será la encargada de adicionar en la matriz el tipo de Tile que se va a dibujar, y de poder obtener el Tile:

public class TileMapLayer
{
  private Tile[][] mapa;

  public TileMapLayer(int ancho, int alto)
  {
    mapa = new Tile[ancho][];
    for (int i = 0; i < ancho; i++)
    {
       mapa[i] = new Tile[alto];
       for (int j = 0; j < alto; j++)
       {
          mapa[i][j] = new Tile(0);
       }
     }
  }
  public void adicionarTile(int indiceX, int indiceY, Tile tile)
{
     mapa[indiceX][indiceY] = tile;
  }

  public Tile obtenerTile(int x, int y)
  {
    return mapa[x][y];
  }

La clase TileMap, es la más importante, ya que es la encargada de dibujar los Tiles, y definir las medidas de los Tiles y de cada Capa que vayamos a adicionar, como se va a dibujar, vamos a crear la clase como un DrawableGameComponent:

public class TileMap : Microsoft.Xna.Framework.DrawableGameComponent

{

   List<TileMapLayer> tileMapLayers = new List<TileMapLayer>();
   SpriteBatch spriteBatch;
   GraphicsDeviceManager graficos;
   TileSheet sheet;
   private Int32 numXTiles;
   private Int32 numYTiles;
   private Int32 anchoCelda;
   private Int32 altoCelda;

   public TileMap(Game game, Int32 tileAncho, Int32 tileAlto, Int32 numXTiles, Int32 numYTiles, TileSheet tileSheet)
            : base(game)
   {
     spriteBatch = (SpriteBatch)Game.Services.GetService(typeof(SpriteBatch));
     graficos = (GraphicsDeviceManager)Game.Services.GetService(typeof(GraphicsDeviceManager));
     sheet = tileSheet;
     this.numXTiles = numXTiles;
     this.numYTiles = numYTiles;
     anchoCelda = tileAncho;
     altoCelda = tileAlto;
    }

    public void agregarCapa(TileMapLayer capa)
    {
      tileMapLayers.Add(capa);
    }

   public override void Initialize()
    {
       base.Initialize();
    }

    public override void Update(GameTime gameTime)
    {
        base.Update(gameTime);
   }
   public override void Draw(GameTime gameTime)
   {
      Rectangle sourceRect = new Rectangle();
      Vector2 scale = Vector2.One;
      foreach (TileMapLayer layer in tileMapLayers)
      {
         for (int x = 0; x < numXTiles; x++)
         {
           for (int y = 0; y < numYTiles; y++)
           {
             Tile tile = layer.obtenerTile(x, y);
             if (tile.Tipo != 0)
             {
                Vector2 position = Vector2.Zero;
                position.X = (float)x * anchoCelda;
                position.Y = (float)y * altoCelda;
                sheet.obtenerRectangulo(ref tile, out sourceRect);
spriteBatch.Draw(sheet.Textura, position, sourceRect, Color.White,     0, Vector2.Zero, scale, SpriteEffects.None, 0.0f);
              }
            }
          }
        }
    }

    public virtual void Show()
    {
      Visible = true;
      Enabled = true;
    }

    public virtual void Hide()
    {
      Visible = false;
      Enabled = false;
    }
 }

Para la clase TileMap, en el constructor recibimos la instancia del juego actual, las medidas de los tiles, la cantidad de Tiles que se van a dibujar y la hoja de texturas de los Tiles.

Se va a tener una lista de Layers, para poder definir varias capas en un solo mapa, también tenemos un método que va a adicionar una capa a la lista de Layers.

El método Draw, recorre la lista de Layers y por cada capa vamos a recorrer la matriz del Mapa, desde la posición 0 hasta el total de Tiles que queremos dibujar, luego obtenemos el Tile que ocupad dicha posición, si es diferente de 0 , o sea que se va a dibujar algo,  declaramos la posición donde lo vamos a dibujar.

Para calcular la posición donde vamos a dibujar el tile, hacemos uso de la posición que ocupa en la matriz y del tamaño del Tile, si hacemos los cálculos nos va a dar:

Position.X = (float)x * anchoCelda;
position.Y = (float)y * altoCelda;

 

Como ven, por cada Tile vamos a obtener la posición de la matriz y a calcular la posición que va a ocupar en la pantalla.

Después de obtener la posición, obtenemos el rectángulo que ocupa la textura del Tile, dependiendo del tipo del Tile que obtuvimos anteriormente:

sheet.obtenerRectangulo(ref tile, out sourceRect);

El tile va como referencia y la función nos devuelve el tamaño del rectángulo, que nos servirá para dibujar el Tile, el rectángulo obtenido, queda en la variable sourceRect y nos sirve para cortar solo la parte del Tile que necesitamos de la textura original:

 

He creado otra clase llamada TileEngine que también es heredada de DrawableGameComponent, y es la encargada de inicializar todas las demás clases, cargar los archivos de texturas, y definir los rectángulos de los Tiles:

public class TileEngine : Microsoft.Xna.Framework.DrawableGameComponent
    {
        /// <summary>
        /// Tipos de Tiles
        /// </summary>

        public enum TileName : int
        {
            Vacio = 0,
            Base = 1,
            Detalle1 = 2,
            Detalle2 = 3,
            Detalle3 = 4,
            Detalle4 = 5,
            Detalle5 = 6,
            Detalle6 = 7,
            Detalle7 = 8,
            Detalle8 = 9,
            Arboles = 10
        }

        Int32 totalTilesX;
        Int32 totalTilesY;
        SpriteBatch spriteBatch;
        GraphicsDeviceManager graficos;
        ContentManager Content;
        Game juego;
        private TileMap mapa;
        private Random rand;
        List<GameComponent> componentesHijos = new List<GameComponent>();
        private TileSheet spritesBosque;

        public TileEngine(Game game, Int32 totalTilesX, Int32 totalTilesY)
            : base(game)
       {
            spriteBatch = (SpriteBatch)Game.Services.GetService(typeof(SpriteBatch));
            graficos = (GraphicsDeviceManager)Game.Services.GetService(typeof(GraphicsDeviceManager));
            Content = (ContentManager)Game.Services.GetService(typeof(ContentManager));
            rand = new Random();
            juego = game;
            this.totalTilesX = totalTilesX;
            this.totalTilesY = totalTilesY;
            LoadContent();
        }

        protected override void LoadContent()
        {
            base.LoadContent();
            Texture2D groundTexture = Content.Load<Texture2D>("tiles2");
            Int32 tamanoTile = 48;
            spritesBosque = new TileSheet(groundTexture);
            spritesBosque.adicionarRectangulo((int)TileName.Base,
                new Rectangle(0, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle1,
                new Rectangle(tamanoTile, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle2,
                new Rectangle(tamanoTile * 2, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle3,
                new Rectangle(tamanoTile * 3, 0, tamanoTile, tamanoTile));

            spritesBosque.adicionarRectangulo((int)TileName.Detalle4,
                new Rectangle(tamanoTile * 4, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle5,
                new Rectangle(tamanoTile * 5, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle6,
                new Rectangle(tamanoTile * 6, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle7,
                new Rectangle(tamanoTile * 7, 0, tamanoTile, tamanoTile));
            spritesBosque.adicionarRectangulo((int)TileName.Detalle8,
                new Rectangle(tamanoTile * 8, 0, tamanoTile, tamanoTile));

mapa = new TileMap(juego, 45, 45, totalTilesX, totalTilesY, spritesBosque);
            componentesHijos.Add(mapa);

            //Se llenan los tiles con valores aleatorios
            //a excepción de la base
            TileMapLayer capa1 = new TileMapLayer(totalTilesX, totalTilesY);
            for (int i = 0; i < totalTilesX; i++)
            {
                for (int j = 0; j < totalTilesY; j++)
                {
                    capa1.adicionarTile(i, j, new Tile(1));
                }
            }
            mapa.agregarCapa(capa1);
            TileMapLayer capa2 = new TileMapLayer(totalTilesX, totalTilesY);
            for (int i = 0; i < totalTilesX; i++)
            {
                for (int j = 0; j < totalTilesY; j++)
                {
                    switch (rand.Next(20))
                    {
                        case 0:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle1));
                            break;
                        case 1:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle2));
                            break;
                        case 2:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle3));
                            break;
                        case 3:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle4));
                            break;
                        case 4:
                        case 5:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle5));
                            break;
                        case 6:
                        case 7:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle6));
                            break;
                        case 8:
                        case 9:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle7));
                            break;
                        case 10:
                        case 11:
                            capa2.adicionarTile(i, j, new Tile((int)TileName.Detalle8));
                            break;
                    }
                }
            }
            mapa.agregarCapa(capa2);
        }
        public override void Initialize()
        {
            base.Initialize();
        }
        public override void Update(GameTime gameTime)
        {
            base.Update(gameTime);
        }
        public override void Draw(GameTime gameTime)
        {
            foreach (GameComponent child in componentesHijos)
            {
                if ((child is DrawableGameComponent) && ((DrawableGameComponent)child).Visible)
                {
                    ((DrawableGameComponent)child).Draw(gameTime);
                }
           }
            base.Draw(gameTime);
        }
        public virtual void Show()
        {
            Enabled = true;
            Visible = true;
            foreach (GameComponent child in componentesHijos)
            {
                child.Enabled = true;
                if (child is DrawableGameComponent)
                {
                    ((DrawableGameComponent)child).Visible = true;
                }
            }
        }
        public virtual void Hide()
        {
            Enabled = false;
            Visible = false;
            foreach (GameComponent child in componentesHijos)
            {
                child.Enabled = false;
                if (child is DrawableGameComponent)
                {
                    ((DrawableGameComponent)child).Visible = false;
                }
            }
        }
        public virtual void Pause()
        {
            Enabled = !Enabled;
            foreach (GameComponent child in componentesHijos)
            {
                child.Enabled = !child.Enabled;
            }
        }
    }

Primero creamos una enumeración que nos servirá para diferenciar los tipos de Tiles que vamos a dibujar, definimos unas variables para tener el total de Tiles que vamos a dibujar, declaramos una clase TileMap, un Random que servirá para generar mapas aleatorior, una lista de componentes,  y el TileSheet que vamos a usar.

La textura que voy a usar es:

 

La clase EngineTile, recibe como parámetros el total de Tiles.

En la función LoadContent, inicializamos y cargamos la textura, asignamos el tamaño del Tile que en este caso va a ser 48×48, luego inicializamos la instancia de la clase TileSheet con la textura que obtuvimos y vamos adicionando los rectángulos de los diferentes tiles, cada rectángulo tendrá un identificador obtenido de la enumeración:

spritesBosque = new TileSheet(groundTexture);
spritesBosque.adicionarRectangulo((int)TileName.Base, new Rectangle(0, 0, tamanoTile, tamanoTile));
 

Adicionamos los rectángulos necesarios,  y luego inicializamos la variable del Mapa y la adicionamos a la lista de componentes del EngineTile:

mapa = new TileMap(juego, 45, 45, totalTilesX, totalTilesY, spritesBosque);

Creamos dos capas, 1 capa va a ser la base del mapa, y vamos a inicializarla todo con 1 que es el identificador de la textura de la base:

TileMapLayer capa1 = new TileMapLayer(totalTilesX, totalTilesY);
for (int i = 0; i < totalTilesX; i++)
{
  for (int j = 0; j < totalTilesY; j++)
  {
     capa1.adicionarTile(i, j, new Tile(1));
  }
}
mapa.agregarCapa(capa1);

Hacemos lo mismo con la segunda capa, pero los datos van a ser aleatorios, haciendo que se generen rocas y plantas en diferentes posiciones.

Ahora para finalizar, debemos declarar la clase EngineTile desde nuestra clase game1 o desde donde la necesitemos:

TileEngine tileEngine;
tileEngine = new TileEngine(game, 10, 10);

Al ejecutarlo, veremos que se dibuja un mapa de 10 x 10:

 

Adicionando una Cámara:

La clase cámara, será de utilidad para simular la vista del Usuario y poder recorrer el mapa cuando es muy extenso.

Clase cámara2D:

public class Camara2D
    {
        private Vector2 posicion;
        private bool camaraCambiada;
        /// <summary>
        /// Posicion de la camara
        /// </summary>

        public Vector2 Posicion
        {
           set
            {
               if (posicion != value)
                {
                    camaraCambiada = true;
                    posicion = value;
                }
            }
            get { return posicion; }
        }

        public bool aCambiado
        {
            get { return camaraCambiada; }
        }
        public Camara2D()
        {
            posicion = Vector2.Zero;
        }
        public void reiniciarCambios()
        {
            camaraCambiada = false;
        }
        public void MoverEjeX(ref float distancia)
        {
            if (distancia != 0)
            {
                camaraCambiada = true;
                posicion.X += distancia;
            }
        }
        public void MoverEjeY(ref float distancia)
        {
            if (distancia != 0)
            {
                camaraCambiada = true;
                posicion.Y -= distancia;
            }
        }
    }

La cámara, tiene una propiedad que es la Posición, está propiedad nos ayudará a retornar la posición que tiene la cámara y a asignar nuevos valores.

La variable Booleana camaraCambiada nos va a ser útil más adelante, para que cuando se haga alguna acción sobre la cámara, podamos saber que se ha cambiado y luego actualizar los demás componentes.

Para el movimiento  hay dos funciones, MoverEjeX y MoverEjeY, cada uno va a modificar el eje X o Y del vector Posición.

Cuando se mueva la posición de la cámara, debemos hacer una operación para modificar la posición del Mapa, ya que en sí lo que se mueve es el Mapa:

 

Modificamos la clase TileEngine, para adicionar la declaración de la cámara y una variable para guardar el centro de la pantalla, que nos servirá como posición inicial de la cámara, justo antes del Constructor:

private Camara2D camara;
private Vector2 centroPantalla;

En el LoadContent, inicializamos el vector que va a guardar el centro de la pantalla y la cámara:

centroPantalla = new Vector2((float)graficos.GraphicsDevice.Viewport.Width / 2f,(float)graficos.GraphicsDevice.Viewport.Height / 2f);/camara = new Camara2D();

 
Para el movimiento de la cámara, declaramos una constrante que tendrá la velocidad en que vamos a moverla:

private const float valorMovimiento = 500f;

Creamos dos funciones, una para verificar la entrada del teclado (las teclas que se oprimen) y otra que nos va a devolver un valor dependiendo de si oprimimos una tecla u otra, está función simula tener un eje y como recibe dos teclas, si es una se aumenta el valor, si es la otra se disminuye el valor:

private static float leerTeclado(KeyboardState teclado, Keys teclaAbajo,Keys teclaArriba)
{
  float valor = 0;
  if (teclado.IsKeyDown(teclaAbajo))
    valor -= 1.0f;
  if (teclado.IsKeyDown(teclaArriba))
    valor += 1.0f;
   return valor;
}

public void administrarEntradaTeclado(float elapsed)
{
   float dX = leerTeclado(currentKeyboardState, Keys.Left, Keys.Right) *
      elapsed * valorMovimiento;
  float dY = leerTeclado(currentKeyboardState, Keys.Down, Keys.Up) *
     elapsed * valorMovimiento;
   camara.MoverEjeX(ref dX);
   camara.MoverEjeY(ref dY);
}

La función de administrarEntadaTeclado, obtiene los valores de la nueva posición de la cámara, está función debe ser llamada en el Update de la clase TileEngine:

public override void Update(GameTime gameTime)
        {
            administrarEntradaTeclado((float)gameTime.ElapsedGameTime.TotalSeconds);
            base.Update(gameTime);
        }

Si ejecutamos el código y movemos las teclas de Derecha, Arriba, Izquierda y Abajo no sucede nada, es porque nos falta mover el mapa con la posición de la cámara, para ello creamos una propiedad llamada PosicionCamara en la clase TileMap, para poder asignarle el valor de la cámara:

private Vector2 posicionCamara;

 
En el Constructor:

posicionCamara = Vector2.Zero;

La propiedad PosicionCamara:

public Vector2 PosicionCamara
{
   set
   {
      posicionCamara = value;
   }
  get
   {
     return posicionCamara;
    }
}

 
Ahora en la clase TileEngine, declaramos una función que nos actualizará la posición de la cámara en la clase TileMap, dicha función solo se llamará cuando la cámara cambie:

public void camaraCambiada()
        {
            //ajustar posicion
            mapa.PosicionCamara = camara.Posicion;
            camara.reiniciarCambios();
        }

La función se llamaría en el Update, debajo de la función administrarEntradaTeclado:

if (camara.aCambiado)
{
    camaraCambiada();
}

Lo último que nos queda es restar la posición del Mapa a la posición de la cámara, dicha operación la podemos hacer con la función Substract de la clase Vector2, en el Draw de la clase TileMap, después de inicializar la posición de cada tile:

Vector2.Subtract(ref posicionCamara, ref position, out position);

 
Si compilamos y ejecutamos, en este momento nos deberían funcionar las teclas de movimiento, haciendo que la cámara simule el movimiento, pero el movimiento lo hace es el mapa y no la cámara, o sea si se mueve la tecla de la derecha, el mapa se mueva a la derecha, y así con las demás teclas, y lo que necesitamos es lo contrario, que al mover la tecla derecha el movimiento sea hacia la izquierda.

Para hacer el movimiento de la cámara, volvemos a la clase TileMap y en el método Draw creamos un vector que tendrá el centro de la pantalla, y luego cambiamos el método Draw haciendo que la posición de los Tiles sean el origen y el centro de la pantalla sea la posición inicial donde se va a dibujar el Sprite:

private Vector2 tamanoPantalla;

Constructor:

tamanoPantalla.X = graficos.GraphicsDevice.PresentationParameters.BackBufferWidth;
tamanoPantalla.Y = graficos.GraphicsDevice.PresentationParameters.BackBufferHeight;

Y el Draw:

spriteBatch.Draw(sheet.Textura, new Vector2(tamanoPantalla.X/2, tamanoPantalla.Y/2), sourceRect, Color.White, 0, position, scale, SpriteEffects.None, 0.0f);

Ahora el Movimiento será mejor.

En la próxima parte, voy a mejorar el dibujado de los Tiles, ya que cuando hay muchos Tiles el consumo de recursos es demasiado, además de adicionarle Zoom y algunas otras cosas.

Código Fuente.

2 pensamientos en “Como hacer un motor de Tiles – Parte 1

  1. Pingback: Como hacer un motor de Tiles – XNA – Parte 2 « Escarbando Código

  2. Hola, estoy examinando el código buscando una formula para usarla en el lenguaje de programación que uso pero no la encuentro, por favor, ¿podrías ayudarme?.
    Yo tengo un array de 15 filas x 16 columnas en el que guardo el numero de tile (perteneciente al tileset) correspondiente que debe ir en el fondo del juego.
    Luego tengo el tileset en una imagen de 13 filas x 10 columnas (todas de 16 x 16 pixels).

    Ok, yo leo el numero de tile del array que dije y luego, ¿como localizo ese tile en el tileset para copiarlo y pegarlo al fondo?.
    Es decir, a partir del numero de tile, ¿como obtengo su coordenada en el tileset para copiar y pegar su correspondiente tile de 16 x 16?.

    Por ejemplo, leo que toca pegar el tile numero 107.
    Ok, yo se que la fila perteneciente al tile número 107 se obtiene así:
    numeroDeTile / numeroDeColumnasDelTileSet
    107 / 10

    Así obtengo la coordenada Y perteneciente al tile numero 107 en el tileset.
    ¿Pero como obtengo su X?.

Deseas comentar o sugerir algo?

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s