Cómo hacer un motor de Tiles – XNA – Parte 2


Mejorando el Motor de Tiles

En el anterior Post ya vimos cómo crear un Mapa aleatorio, crear capas, mover la cámara y poder hacer un recorrido a todo el Mapa creado.

Si aumentamos el total de tiles que se van a dibujar, veremos que nuestro sistema se esfuerza al mover la cámara, esto es causado porque siempre estamos dibujando todo el mapa, y lo que se debe hacer es dibujar solo los Tiles visibles en la pantalla, por ejemplo en la siguiente imagen, el Mapa es mucho más grande que la pantalla y consumiríamos muchos recursos al dibujar todo el Mapa, lo que se debe dibujar es la parte en verde (aunque no se ve muy verde, es la parte que está en el recuadro de Pantalla):

 

El truco, es obtener con la posición de la pantalla los puntos Mínimos y Máximos de la pantalla, y saber que Tiles son, así podemos dibujar solo los Tiles que se encuentran dentro de la pantalla.

Se deben crear  dos métodos nuevos en la clase TileMap:

public Int32 obtenerCeldaporPixelX(Int32 pixelX)
{
   return pixelX / anchoCelda;
}
public Int32 obtenerCeldaporPixelY(Int32 pixelY)
{
   return pixelY / altoCelda;
}

Estos métodos ayudarán a saber qué valor representa un pixel de la pantalla al Mapa, para ello se recibe como parámetro un punto y luego se divide por el ancho o alto del Tile.

Ahora se crea el método que devolverá los Puntos máximo y mínimo de los Tiles Visibles en Pantalla, pero antes creo dos variables del tipo Point para guardar los puntos, se usa la clase Point para guardar valores enteros y no un Vector2 que guarda valores tipo float y para esta parte se necesitan valores enteros, aunque viendo el código del anterior proyecto veo que ya existen, si no existen se crean:

Point min;
Point max;

Ahora sí el método:

private void obtenerVisibilidad()
{
  // obtengo los puntos de la pantalla
  min.X = obtenerCeldaporPixelX((int)posicionCamara.X);
  max.X = obtenerCeldaporPixelX((int)posicionCamara.X + (int)(tamanoPantalla.X));
  min.Y = obtenerCeldaporPixelY((int)posicionCamara.Y);
  max.Y = obtenerCeldaporPixelY((int)posicionCamara.Y + (int)(tamanoPantalla.Y));
}

Como pueden ver, el método obtiene el punto min. X con la posición de la cámara en el eje X, y para obtener el punto max.X, lo que se hace es obtener el punto de la suma de la posición de la cámara más el tamaño de la pantalla, lo mismo sucede para obtener el eje Y.

Ahora se modifica el Draw:

obtenerVisibilidad();
foreach (TileMapLayer layer in tileMapLayers)
{
  for (int x = min.X; x < max.X; x++)
  {
   for (int y = min.Y; y < max.Y; y++)
   {
…
spriteBatch.Draw(sheet.Textura, Vector2.Zero, sourceRect, Color.White,
                 0, position, scale, SpriteEffects.None, 0.0f);

Al compilar se puede ver que no se dibuja todo el mapa, en las esquinas se alcanza a ver el fondo:

 

Esto se debe a que los datos que nos dan para los puntos máximo alcanzan a pedazos de otros tiles y como las divisiones no dan exactas puede dar un valor como 16.35555 y al convertirlo a entero se queda en 16, para corregir lo que se hace es sumarle 1 a los puntos máximos, haciendo que dibuje un Tile de más:

max.X = obtenerCeldaporPixelX((int)posicionCamara.X + (int)(tamanoPantalla.X)) + 1;
max.Y = obtenerCeldaporPixelY((int)posicionCamara.Y + (int)(tamanoPantalla.Y)) + 1;

Ahora sí se ve mejor, como ven el movimiento es mucho más rápido que en el post Anterior, si mueven la pantalla y llegan a los límites del Mapa, va a ocurrir una excepción “IndexOutOfRangeException” debido a que intenta obtener un Tile con valores negativos, para corregirlo se puede limitar el movimiento de la pantalla y limitar los valores obtenidos:

private void obtenerVisibilidad()
{
// obtengo los puntos de la pantalla
  min.X = obtenerCeldaporPixelX((int)posicionCamara.X);
  max.X = obtenerCeldaporPixelX((int)posicionCamara.X + (int)(tamanoPantalla.X)) + 1;
  min.Y = obtenerCeldaporPixelY((int)posicionCamara.Y);
  max.Y = obtenerCeldaporPixelY((int)posicionCamara.Y + (int)(tamanoPantalla.Y)) + 1;
  // limitamos los valores
  min.X = (int)MathHelper.Clamp((float)min.X, 0, numXTiles);
  min.Y = (int)MathHelper.Clamp((float)min.Y, 0, numYTiles);
  max.X = (int)MathHelper.Clamp((float)max.X, 0, numXTiles);
  max.Y = (int)MathHelper.Clamp((float)max.Y, 0, numYTiles);
}

El método Clamp sirve para limitar los valores, en este caso van de “cero” hasta el total de Tiles que se haya indicado.

Como dije, también se puede limitar el movimiento de la cámara, para que solo se mueva en el Mapa, aprovecho para hacerle unas modificaciones a la clase cámara, como dejándola Static, al hacerla static se puede tener acceso a los datos sin necesidad de instanciar la clase, también le adiciono algunas propiedades:

public static class Camara2D
{
  private static Vector2 posicion = Vector2.Zero;
  private static bool camaraCambiada = false;
  public static Vector2 Posicion
  {
   set
    {
      if (posicion != value)
      {
       camaraCambiada = true;
       posicion = value;
      }
    }
    get { return posicion; }
  }
 public static bool aCambiado
 {
   get { return camaraCambiada; }
 }
 public static void reiniciarCambios()
 {
   camaraCambiada = false;
 }
 public static Vector2 TamanoPantalla { get; set; }
 public static Int32 numXTiles { get; set; }
 public static Int32 numYTiles { get; set; }
 public static Int32 anchoTile { get; set; }
 public static Int32 altoTile { get; set; }
 public static Rectangle rectMundo
 {
  get
   {
    return new Rectangle(0, 0, (numXTiles * anchoTile), (numYTiles * altoTile));
   }
 }
 public static void MoverEjeX(ref float distancia)
 {
  if (distancia != 0)
   {
    camaraCambiada = true;
    posicion.X += distancia;
   }
 }
 public static void MoverEjeY(ref float distancia)
 {
  if (distancia != 0)
  {
   camaraCambiada = true;
   posicion.Y -= distancia;
  }
 }
}

Con las nuevas propiedades le indicamos a la cámara el tamaño del Tile, el tamaño de la misma cámara (TamanoPantalla) y el del Mapa.

Si se compila ocurrirá un error, porque se está declarando una instancia de la clase cámara, se debe eliminar lo siguiente:

private Camara2D camara;
camara = new Camara2D();

Y donde se tenga “camara” se debe cambiar por “Camara2D”:

mapa.PosicionCamara = Camara2D.Posicion;
Camara2D.reiniciarCambios();
Camara2D.reiniciarCambios();
administrarEntradaTeclado((float)gameTime.ElapsedGameTime.TotalSeconds);
if (Camara2D.aCambiado)
{
  camaraCambiada();
}
Camara2D.MoverEjeX(ref dX);
Camara2D.MoverEjeY(ref dY);

Ahora que no hay ningún error al compilar, se modifica la propiedad Posicion de la cámara para limitarla:

public static Vector2 Posicion
{
  set
  {
   if (posicion != value)
   {
    camaraCambiada = true;
    posicion = new Vector2(
                     MathHelper.Clamp(value.X, rectMundo.X, rectMundo.Width - TamanoPantalla.X),
                     MathHelper.Clamp(value.Y, rectMundo.Y, rectMundo.Height - TamanoPantalla.Y));
   }
  }
  get { return posicion; }
}

Nuevamente se está usando el método Clamp, se limita a el valor que se encuentra solo entre el 0 que es el valor rectMundo.X y la diferencia del tamaño del Mapa con el tamaño de la pantalla.

Si compilan, no verán nada y que la cámara se sigue saliendo del Mapa, es debido a que la propiedad Posicion no es cambiada, lo que cambia es la variable posición.X y posición.Y, se debe crear un nuevo método para que la pantalla se mueva:

public static void Mover(ref Vector2 distancia)
{
  if (distancia != Vector2.Zero)
  {
    Posicion += distancia;
  }
}

También se modifica la clase TileEngine para enviar los parámetros del movimiento:

public void administrarEntradaTeclado(float elapsed)
{
  float dX = leerTeclado(currentKeyboardState, Keys.Left, Keys.Right) *
                       elapsed * valorMovimiento;
  float dY = leerTeclado(currentKeyboardState, Keys.Up, Keys.Down) *
                       elapsed * valorMovimiento;
  Vector2 distancia = new Vector2(dX, dY);
  Camara2D.Mover(ref distancia);
}

Antes de desanimarse al ejecutar y no ver lo que se quiere, se tiene que indicar a la cámara el tamaño del Tile, de la Pantalla y del Mapa, en la clase donde se esté inicializando la clase TileEngine, justo después de inicializarla se hace lo siguiente:

Camara2D.TamanoPantalla = new Vector2(800, 600);
Camara2D.altoTile = 48;
Camara2D.anchoTile = 48;
Camara2D.numXTiles = 100;
Camara2D.numYTiles = 100;

Esto no se debe hacer en la clase TileEngine, para tener un mejor manejo de los datos y además poder cambiarlos para otros proyectos, se debe tener en cuenta que los datos deben coincidir con los que se inicializaron la clase TileEngine, en el Post anterior cometí el error de inicializar el tamaño del Tile en 45 y no en 48 que es el tamaño real:

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

Se debe corregir y dejar el tamaño del Tile en 48:

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

Ahora si se puede ver que la cámara solo se mueve hasta el tamaño del Mapa.

Una mejora que se puede hacer al motor, es que el método que obtiene los Tiles visibles solo sea llamado cuando la cámara sufra algún cambio, para ello se declara una variable booleana y cada vez que se modifique la propiedad posición, se deja en True la variable booleana, y cuando se encuentre en True se llamaría el método para obtener los Tiles visibles:

private bool visibilidadCambiada;
public Vector2 PosicionCamara
{
  set
  {
   posicionCamara = value;
   visibilidadCambiada = true;
  }
  get
  {
   return posicionCamara;
  }
}

Método Draw:

if (visibilidadCambiada)
{
  obtenerVisibilidad();
  visibilidadCambiada = false;
}

Aplicando el Zoom al Mapa:

Se modifica la clase Camara2D para adicionar la propiedad Zoom:

private static float zoom = 1.0f;
public static float Zoom
{
 set
 {

  if (zoom != value)
  {
   camaraCambiada = true;
   zoom = value;
  }
 }
  get { return zoom; }
}

El truco para aplicar el Zoom al mapa esta en el parámetro scale del método Draw , se crea una propiedad Zoom en la clase TileMap, y dos variables que guardarán el zoom y la escala por la cual se dibujarán los Tiles:

private float zoom;
private Vector2 escala;

En el Constructor:

escala = Vector2.One;
zoom = 1.0f;

Propiedad:

public float ZoomCamara
{
  set
  {
   zoom = value;
   visibilidadCambiada = true;
  }
  get
  {
  return zoom;
  }
}

En el Draw, después de restar la posición de la cámara con la del Mapa, multiplicamos el vector escala con el valor del zoom, y se guarda en la variable scale del método Draw:

Vector2.Multiply(ref escala, zoom, out scale);

En el TileEngine también se deben hacer algunas modificaciones, como indicar el valor del Zoom

private const float valorZoom = 0.5f;

En administrarEntradaTeclado, adicionamos la lectura de las teclas que modificarán el Zoom, además de limitarlo para que solo sea entre 0.5 y 2:

dX = leerTeclado(currentKeyboardState, Keys.X, Keys.Z) * elapsed * valorZoom;
//limitar el zoom
Camara2D.Zoom += dX;
Camara2D.Zoom = MathHelper.Clamp(Camara2D.Zoom, .5f, 2f);

En el método camaraCambiada, actualizamos las propiedades Zoom de la clase Mapa con la que se tiene la Cámara:

mapa.PosicionCamara = Camara2D.Posicion;
mapa.ZoomCamara = Camara2D.Zoom;

Si compilamos y oprimimos Z ó X, vemos como se aumenta y disminuye el tamaño del Mapa:

Para hacer que el dibujado de los Tiles visibles se haga usando el zoom y simular que la cámara sea la que haga zoom y no el Mapa, se debe modificar el método que obtiene los Tiles para hacer que se dibujen más o menos Tiles con el Zoom:

max.X = obtenerCeldaporPixelX((int)posicionCamara.X + (int)(tamanoPantalla.X / zoom)) + 1;
min.Y = obtenerCeldaporPixelY((int)posicionCamara.Y);
max.Y = obtenerCeldaporPixelY((int)posicionCamara.Y + (int)(tamanoPantalla.Y / zoom)) + 1;

En el anterior Post, se había cambiado el método Draw para que la variable position, que era la que contenía la posición de los Tiles en el Mapa, se usará como origen, se debió a que tenía pensado utilizar el Zoom,  ya que si se dejara la variable position en el parámetro posición y el Vector2.Zero en el parámetro origen al aplicar el zoom se vería así:

spriteBatch.Draw(sheet.Textura, position, sourceRect, Color.White,
                0, Vector2.Zero, scale, SpriteEffects.None, 0.0f);

Otra cosa que hay que recalcar, es que el Zoom es aplicado al parámetro posición del método Draw, cuando lo dejamos en Vector2.Zero, vemos que el zoom se ve aplicado a la esquina superior izquierda.

Otras Mejoras al Motor:

Crear una variable pública que instancia a la clase TileSheet en la clase TileMapLayer, para que cada mapa tenga un tileSheet diferente, y tener una propiedad booleana que informe si la capa se encuentra activa o no, si se encuentra activa se puede dibujar:

public TileSheet sheet;
public Boolean Activo { get; set; }
public TileMapLayer(int ancho, int alto, TileSheet tileSheet, Boolean activo)
{
  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);
    }
  }
  sheet = tileSheet;
  Activo = activo;
}

Dejar Pública la lista de Capas en la Clase TileMap y la variable que guarda el tamaño de la pantalla, luego se debe modificar la inicialización de la variable tamanoPantalla ya que se inicializaran desde fuera de la clase:

public List<TileMapLayer> tileMapLayers = new List<TileMapLayer>();
public Vector2 tamanoPantalla;
<span style="text-decoration: line-through;">tamanoPantalla.X = graficos.GraphicsDevice.PresentationParameters.BackBufferWidth;</span>
<span style="text-decoration: line-through;">tamanoPantalla.Y = graficos.GraphicsDevice.PresentationParameters.BackBufferHeight;</span>

tamanoPantalla = Vector2.Zero;

Eliminar el parámetro TileSheet de la clase TileMap, y modificar el Draw para que solo dibuje las capas que se encuetran activas y el TileSheet de cada capa:

public TileMap(Game game, Int32 tileAncho, Int32 tileAlto, Int32 numXTiles, Int32 numYTiles)
...
foreach (TileMapLayer layer in tileMapLayers)
{
if (layer.Activo)
  {
   for (int x = min.X; x < max.X; x++)
   {
…
layer.sheet.obtenerRectangulo(ref tile, out sourceRect);
spriteBatch.Draw(layer.sheet.Textura, Vector2.Zero, sourceRect, Color.White,
                0, position, scale, SpriteEffects.None, 0.0f);

Dejar Pública la variable mapa en la clase TileEngine:

public TileMap mapa;

Crear una lista púbica de los TileSheet que se tienen en la clase TileEngine:

public List<TileSheet> tileSheets = new List<TileSheet>();

Crear una propiedad pública que tenga el tamaño de los Tiles:

public Int32 TamTiles { get; set; }

Adicionar el tamaño del Tile como parámetro en la clase TileEngine, inicializar la propiedad TamTiles con el nuevo parámetro adicionado, inicializar la variable mapa y adicionarlo a los componentes hijos, el nuevo constructor queda así:

public TileEngine(Game game, Int32 totalTilesX, Int32 totalTilesY, Int32 tamTiles)
TamTiles = tamTiles;
mapa = new TileMap(juego, TamTiles, TamTiles, totalTilesX, totalTilesY);
componentesHijos.Add(mapa);

Ahora se elimina la inicialización de las capas y la creación del Mapa con los valores aleatorios dentro de la clase TileEngine, se debe eliminar lo siguiente:

Texture2D groundTexture = Content.Load<Texture2D>("tiles2");
Texture2D arbolesTextura = Content.Load<Texture2D>("arboles");
Int32 tamanoTile = 48;
spritesArbol = new TileSheet(arbolesTextura);
spritesArbol.adicionarRectangulo((int)TileName.Arboles,
         new Rectangle(0, 254, 127, 127));
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, 48, 48, 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);

Crear un nuevo método en la clase TileEngine, que permita inicializar todo:

private void reiniciarTodo()
{
   Camara2D.TamanoPantalla = mapa.tamanoPantalla;
  Camara2D.Posicion = Vector2.Zero;//centroPantalla;
   Camara2D.Zoom = 1f;
   camaraCambiada();
}

Este método debe ser llamado en el LoadContent y en administrarEntradaTeclado:

if (currentKeyboardState.IsKeyDown(Keys.R))
{
  reiniciarTodo();
}

Ahora en la clase Game1 o en la que se vaya a declarar la clase TileEngine, se inicializan y se crea un nuevo mapa:

tileEngine = new TileEngine(game, 100, 100, 48);
tileEngine.mapa.tamanoPantalla = new Vector2(800, 600);
Camara2D.TamanoPantalla = tileEngine.mapa.tamanoPantalla;
Camara2D.altoTile = 48;
Camara2D.anchoTile = 48;
Camara2D.numXTiles = 100;
Camara2D.numYTiles = 100;
Texture2D spriteSheet = Content.Load<Texture2D>("tiles2");
tileEngine.tileSheets.Add(new TileSheet(spriteSheet));
tileEngine.mapa.agregarCapa(new TileMapLayer(100, 100, tileEngine.tileSheets[0], true));
tileEngine.tileSheets[0].adicionarRectangulo(1, new Rectangle(0, 0, tileEngine.TamTiles, tileEngine.TamTiles));
for (int i = 0; i < 100; i++)
   {
   for (int j = 0; j < 100; j++)
    {
     tileEngine.mapa.tileMapLayers[0].adicionarTile(i, j, new Tile(1));
    }
   }

Todo esto se hizo para que la clases sean usadas en diferentes proyectos y se tenga una mayor administración de todos los componentes y las propiedades de cada uno.

Código Fuente Aquí.

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