Noticias:

Acaba el experimento social llamado Gran Hermano. Ahora nunca más volveremos a saber de sus protagonistas

Menú Principal

Curso de Programación Lúdica. Actualmente: Tetris funcionando

Iniciado por Bill, 13 de Mayo de 2009, 15:08

0 Miembros y 1 Visitante están viendo este tema.

Khram Cuervo Errante

Si me pasas una dirección de mail, puedo mandarte la imagen estática para que te hagas una idea. Si aceptas el trabajo, ya te pasaría una chuleta para que se viera el orden en el que tienen que ocurrir las cosas.

Sorry but you are not allowed to view spoiler contents.

Bill

7. Creando un Tetris: Análisis

Lo que hemos experimentado hasta ahora era de forma caótica, es decir, se programaba para lo que se necesitaba en cada momento. Esto, que es un error en el que suelen caer todos los que empiezan y muchos de los que ya no son novatos, es pan hoy y hambre mañana. Por ejemplo, suponed el diseño de enemigos, tendríamos que repetir código, como mínimo el de recorte de gráficos.

Lo normal antes de comenzar un programa es el análisis, de forma que que obtengamos una idea de lo que vamos a tener que programar, qué estructuras utilizar, cómo programarlo, o incluso una estimación del coste y el esfuerzo. En juegos, más allá del análisis para un programa, está el análisis para un motor, que básicamente responde a la pregunta "¿éste será el único juego que voy a programar?". Toda compañía de juegos trabaja contra un motor, no parten de cero, de forma que gran parte del trabajo gráfico o lógico está ya automatizado.

En nuestro caso vamos a hacer un análisis de programa para el tetris, y una vez terminado el desarrollo se comenzará con XNA, dónde veremos lo báisico para luego adentrarnos en el desarrollo de un simple motor gráfico 2D y más tarde 3D.

El Tetris

En el tetris poseemos un espacio de juego cuadriculado de unas dimensiones determinadas en el que caen de la parte superior figuras geométricas que se denominan "tetriminos". El jugador puede girar dichas fichas en ángulos de 90º, moverlas a izquierda y derecha o acelerar su caída. Cuando alguna parte del tetrimino topa con un bloque estacional (por ejemplo una pieza que ya había sido colocada), queda colocada y se obtiene un tetrimino nuevo al azar. Cuando una fila del espacio de juego está repleta por bloques de tetriminos, dicha fila desaparece y las superiores a ella bajan una posición.

Las piezas originales son 7, que se denotan con las letras que mejor las definen: O, I, S, Z, J, L y T.
Si representamos dichas piezas en cuadrículas de 4x4, podremos representar también sus diferentes posiciones rotándolas a partir de un cuadrado escogido como pivote, en nuestro caso el (1,1):



Se puede observar que la pieza O solamente tiene una posibilidad, dado que aunque se rote se queda igual (por eso para ella su caso es especial y no se utiliza el pivote de giro, que daría un resultado desagradable). Las piezas I, S y Z tienen dos posiciones, y las piezas J, L y T tienen 4 posiciones.
A cada una de las piezas le hemos dado un color correspondiente para diferenciarlas.

En nuestro caso, por darle un aspecto más moderno, las piezas se mostrarán en una perspectiva isométrica, y el tablero se representará relleno de piezas blancas en el inicio. Además, no se utilizará un ancho y alto fijo del espacio de juego, sino que será definible. Necesitaremos una imagen para los sprites de los cubos que contendrá 8 cubos (uno blanco y los 7 de cada color):



Cada cubo tiene un ancho de 21px y un alto de 21px. Sin embargo, a la hora de mostrarlos apilados, hay que tener en cuenta que un cubo encima de otro está en su misma x pero en el incremento en y no es de 21px sino de 10px, cuando un cubo está a la derecha de otro el incremento en x es de 10px y además debe bajar 5px de altura para la emulación de la isométrica.



A la hora de pintar también hay que tener algo importante en cuenta: el orden de pintado. Los sprites de los cubos se van a superponer así que hay que contemplar el orden de pintado para que no pasen cosas extrañas. Las posibilidades son 4:



Como vemos, la forma correcta es pintar comenzando por abajo y hacia arriba.

Ahora hay que analizar las estructuras que se utilizarán para la aplicación y datos básicos que necesitaremos:

1. Necesitaremos cargar al inicio la imagen con los cubos de colores y partirla en sus diferentes Bitmaps.
2. Necesitaremos información sobre cada pieza y sus diferentes giros. Para esto utilizaremos una matriz de 4 dimensiones:
- la primera dimensión se corresponderá con la pieza, es decir, la pieza 0 será la O, la 1 será la I, la 2 será la S y así sucesivamente
- la segunda dimensión se corresponderá con la posición de la pieza, con lo cual la posición 0 será rotación de 0º, posición 1 será 90º, posición 2 será 180º y posición 3 será 270º.
- la tercerá dimensión será el y de la pieza
- la cuarta dimensión será el x de la pieza.
De esta forma, dada una pieza y posición, obtendremos una matriz de 2 dimensiones 4x4 que nos dirá para cada casilla qué hay que pintar, significando el 0 que la casilla está vacía (lo cual no origina colisiones), el 1 el cubo de color rojo, el 2 el cubo de color verde, el 3 el cubo de color azul y así sucesivamente.
3. Necesitamos el número de piezas, cantidad de estados de pieza, ancho en casillas de la pieza y alto en casillas de la pieza como constantes, dado que será fijo.
4. Necesitamos el ancho y alto de cada pieza para cargar sus sprites, y será definible.
5. Necesitamos el incremento de ancho, incremento de alto e incremento de alto cuando hay cambio de x, que es lo que hemos definido antes con las cotas, y que será también definible dado que lleva relación con el ancho y alto de la pieza.
6. Además necesitaremos la posición izquierda y de alto en las que comenzar a pintar dentro del canvas de la aplicación.
7. Hace falta llevar control del espacio de juego, que será una matriz.
8. También hace falta llevar control de la pieza y detección de colisiones para calcular si hay giros posibles o si hay que posicionarla y sacar una pieza nueva.
9. Control de finalización de juego cuando no es posible sacar pieza nueva.
10. Cálculo de si en el espacio de juego hay filas completas para eliminarlas y bajar las superiores.

A partir de estos datos construiremos una clase TetrisEngine que será la encargada de representar al motor del tetris, y que será llamada por el formulario principal el cual le proporciona en el evento OnPaint su canvas para que pinte. Además sabemos que hay un cambio con respecto al ejercicio anterior: en el ejercicio anterior había un timer para los FPS y estaba definido en el formulario, el cual forzaba al repintado cada cierto tiempo. En este caso es el propio TetrisEngine el que tendrá un timer que será menor cuanto mayor sea la dificultad, con lo cual lanzará un evento que será leído por el formulario principal para producir su repintado, y que además comprueba si hay líneas conseguidas o si hay que sacar una pieza nueva. Además, el formulario principal informa al TetrisEngine de los movimientos del jugador (flecha arriba => girar, flecha izquierda, flecha derecha, flecha abajo => bajar una posición, barra espaciadora => tirar pieza), y el TetrisEngine procesará si es un movimiento válido, y en caso de serlo lanzará el evento para el repintado.

Además hay que pensar en el futuro, se planean los siguientes datos para el tetris:
- Visión previa de la siguiente pieza.
- Número de líneas conseguidas.
- Nivel de dificultad actual.
- Puntuación.
- Menú de juego.
- Música y efectos de sonido.
- Multijugador on-site (por decidir).
- Multijugador on-line (por decidir).

Un ejemplo de cómo quedaría el juego inicializado mostrando el espacio de juego vacío, que será lo primero a conseguir en el desarrollo:



Ahora que tenemos las ideas un poquito más claras se puede comenzar a programar.

Bill

8. Creando un Tetris: Sprites

8. Creando un Tetris: Sprites

Vamos a crear la aplicación y la dividiremos en 5 trozos diferenciados:
1- El formulario principal, que incluirá la interacción con el usuario y nos dará el canvas para pintar.
2- Una clase para cargar, manejar y pintar los sprites del tetris, que además incluya cálculos para la isométrica (TetrisSprite)
3- Una clase para representar a una pieza de tetris (TetrisPiece)
4- Una clase para representar a un espacio de juego del tetris (TetrisSpace)
5- El motor del tetris que se encarga de unirlo y moverlo todo (TetrisEngine)

Por definición, el formulario principal incluirá una instancia de TetrisEngine, que a su vez incluirá una instancia de TetrisSpace, la cual incluye una instancia de TetrisPiece. La clase TetrisSprite, por el contrario, necesita ser utilizada tanto por el Engine, Space y Piece, dado que en ella centralizaremos todo el pintado gráfico. A su vez, TetrisEngine informará al formulario principal de cuándo debe ser repintado mediante un evento.

Lo primero de todo es crear nuestra solución escogiendo de tipo de proyecto aplicación de Windows Forms.

La clase TetrisSprite

La clase TetrisSprite vamos a diseñarla según los siguientes datos:
1. Debe poder ser utilizada por diversas clases, pero solamente debemos permitir una instancia. Para eso utilizaremos el patrón de diseño singleton que nos asegurará que existe una única instancia de la clase TetrisSprite.
2. Debe permitir cargar los sprites de una única imagen sabiendo que dicha imagen los contiene en horizontal uno detrás de otro y pasándole la cantidad de sprites que contiene la imagen como parámetro.
3. Debe permitir que se especifique si es perspectiva isométrica o no, y hacer los correspondientes cálculos de los incrementos de X e Y que debe de utilizar en cada caso.
4. Debe contemplar que existen 3 clases de zonas en el juego: casillas ocupadas por una pieza, casillas no ocupadas y que se corresponden a la zona de juego, casillas que no están ocupadas y que se corresponden a la zona de drop por dónde caen las piezas.
5. Para cada zona debe poderse especificar la transparencia en Alpha Blend de lo que vaya a pintar.
6. Debe contener métodos para el pintado que hagan el desarrollo futuro más sencillo.

Patrón de diseño singleton
En desarrollo informático los patrones de diseño son una parte importantísima, sobre todo a la hora de elaborar el diseño técnico de una aplicación. Son soluciones a problemas comunes que deben ser efectivas y reusables, y existen unos cuantos de ellos. Uno de los más comunes es el patrón singleton, que nos asegura que una determinada clase se instanciará una única vez, y nos permitirá acceder a esa instancia. En principio puede parecer tarea sencilla, pero se complica cuando pensamos que una aplicación puede tratar de acceder desde dos puntos diferentes de un mismo hilo cuando todavía no está creada la instancia y generar problemas, y todavía peor, en aplicaciones multihilo hay que proteger todavía más esta primera invocación.

En C# para elaborar una solución óptima al problema del singleton tenemos que seguir varios pasos:
1º) El constructor debe estar protegido, así que cambiaremos su visibilidad a privada
2º) Necesitamos un campo estático, inicializado a null, para almacenar la instancia.
3º) Necesitamos una propiedad estática de solo lectura y pública que nos dé acceso a esta instancia, pero que en caso de ser null la cree nueva.
4º) La creación debe estar protegida en una sección crítica. En c# es utilizando la instrucción lock(obj) dónde obj es un objeto cualquiera para la sincronización, y claramente debe ser declarado estático.
5º) Además de protegida por una sección crítica, estará protegida del acceso simultáneo desde hilos diferentes, y esto nos lo asegura el atributo  [MethodImpl(MethodImplOptions.Synchronized)], contenido en System.Runtime.CompilerServices. Este atributo es aplicable a métodos, así que la verdadera creación de la instancia estará en un método privado.

Veamos cómo quedaría nuestra clase TetrisSprite con el singleton implementado:

Citarpublic class TetrisSprite
    {
        private static Object syncObject = new Object();

        private static TetrisSprite instance = null;

        public static TetrisSprite Instance
        {
            get { return GetInstance(); }
        }

        private TetrisSprite()
        {
        }

        [MethodImpl(MethodImplOptions.Synchronized)]
        private static TetrisSprite GetInstance()
        {
            lock (syncObject)
            {
                if (instance == null)
                {
                    instance = new TetrisSprite();
                }
                return instance;
            }
        }
    }

Cualquiera que quiera utilizar la instancia de TetrisSprite solamente tiene que utlizar la propiedad pública y estática TetrisSprite.Instance

Uso de regiones

Hay algo muy útil en C# que son las regiones. Las regiones no es código que se compile, sino que es una ayuda para organizar y visualizar el código. Lo que hace una región es que lo de dentro de ella se pueda "plegar", por ejemplo:

Citar#region Prueba
(aquí el código)
#endregion

Podríamos plegarlo o desplegarlo para que solamente muestre la palabra "Prueba" o todo el código. En ese sentido, es bueno ser muy organizado con el código y utilizar regiones, aseguro que son muy útiles.



Recorte y uso de los sprites

Para el uso de los sprites necesitaremos diversos campos, y sus respectivas propiedades de solo lectura:
1- Un array con los Bitmaps recortados
2- Un Rectangle con el área ya calculada de esos Bitmaps
3- El incremento en X cuando nos desplazamos horizontalmente (la mitad del ancho si es isométrica o el ancho entero si es plano)
4- El incremento en Y cuando nos desplazamos verticalmente (la mitad de la altura si es isométrica o la altura entera si es plano)
5- El incremento en Y cuando nos desplazamos horizontalmente (un cuarto de la altura si es isométrica o 0 si es plano)

Citarprivate Bitmap[] sprites;

        private Rectangle spriteRect;

        private int xIncrement;

        private int yIncrement;

        private int yIncrementForX;

        public Bitmap[] Sprites
        {
            get { return sprites; }
        }

        public Rectangle SpriteRect
        {
            get { return spriteRect; }
        }

        public int XIncrement
        {
            get { return xIncrement; }
        }

        public int YIncrement
        {
            get { return yIncrement; }
        }

        public int YIncrementForX
        {
            get { return yIncrementForX; }
        }

Además necesitamos un método público con el que pasarle el Bitmap que contiene los sprites, la cantidad de sprites que contiene y una booleana para indicar si es isométrica:

Citarpublic void Initialize(Bitmap srcBitmap, int numSprites, bool isIsometric)
        {
            // Calculamos el Rectangle de cada sprite. El ancho será el ancho total del bitmap que entra
            // dividido entre el número de sprites que contiene
            spriteRect = new Rectangle(0, 0, srcBitmap.Width / numSprites, srcBitmap.Height);
            // Inicializamos nuestros campos de desplazamiento dependiendo de la perspectiva
            if (isIsometric)
            {
                xIncrement = spriteRect.Width / 2;
                yIncrement = spriteRect.Height / 2;
                yIncrementForX = -spriteRect.Height / 4;
            }
            else
            {
                xIncrement = spriteRect.Width;
                yIncrement = spriteRect.Height;
                yIncrementForX = 0;
            }
            // Creamos el array para almacenar los bitmaps
            sprites = new Bitmap[numSprites];
            // Creamos un rect que será para recorrer el bitmap que nos ha llegado y partirlo en trozos.
            Rectangle srcRect = new Rectangle(0, 0, spriteRect.Width, spriteRect.Height);
            // Recorremos cada uno de los sprites y lo pintamos en el bitmap de la posición del array.
            for (int i = 0; i < numSprites; i++)
            {
                sprites = new Bitmap(spriteRect.Width, spriteRect.Height);
                Draw(sprites, srcBitmap, spriteRect, srcRect);
                srcRect.X += srcRect.Width;
            }
        }

        public void Draw(Bitmap target, Bitmap source, Rectangle destRect, Rectangle srcRect)
        {
            Graphics bitmapGraphics = Graphics.FromImage(target);
            bitmapGraphics.DrawImage(source, destRect, srcRect.X, srcRect.Y, srcRect.Width, srcRect.Height, GraphicsUnit.Pixel);
        }

Como vemos, hemos creado un método público adicional que nos ayudará en el pintado de un bitmap a otro.

Transparencia Alpha Blending

Vimos ya varias veces que la clase Graphics tiene un método Draw con muchísimas sobrecargas. Varias de ellas aceptan un tipo de parámetro que es una instancia de ImageAttributes. Este ImageAttributes contiene diversas modificaciones que se pueden realizar sobre una imagen y una de ellas es que puede poseer una matriz de colores (ColorMatrix) que es una matriz de transformación de colores en el plano RGB.

El proceso, básicamente, consiste en crear una instancia de ColorMatrix, una instancia de ImageAttributes y usar el método SetColorMatrix de ImageAttributes para pasar la matriz. Pero, ¿cómo cambio la opacidad usando esa matriz? No entraré a explicarla en profundidad porque tiene muchísima miga, pero básicamente en su propiedad Matrix33 podemos modificar una diagonal que afecta a la opacidad de cada color, y en la que pondremos un número desde 0.0 a 1.0, significando el 0.0 que es transparente y el 1.0 que es totalmente opaco.

Lo primero que haremos será crear un enumerado que defina el tipo de zona que pintamos:

Citarpublic enum SpriteZone
    {
        game,
        drop,
        piece
    }

Ahora creamos una matriz de 3 elementos float para almacenar las opacidades y otra de 3 elementos ImageAttributes para almacenar los tres tipos de ImageAttributes que vamos a tener. La matriz de opacidades la inicializamos, la posición 0 es game, la posición 1 es drop y la posición 2 es piece. Como vemos, el juego y las piezas las mostramos opacas, la zona de drop bastante transparente (20%).

Citarprivate float[] opacities = new float[3] { 1.0f, 0.2f, 1.0f };

        private ImageAttributes[] imageAttributes;

Creamos un método privado que pasándole una posición aplique la transparencia de opacities en imageAttributes:

Citarprivate void ApplyOpacity(int i)
        {
            ColorMatrix cm = new ColorMatrix();
            cm.Matrix33 = opacities;
            imageAttributes.SetColorMatrix(cm);
        }

Creamos una propiedad para poder leer y modificar la opacidad de cada una de las zonas. Casteando un enumerado a entero obtenemos su valor entero y así podemos utilizarlo en las matrices:

Citarpublic float GameZoneOpacity
        {
            get { return opacities[(int)SpriteZone.game]; }
            set
            {
                opacities[(int)SpriteZone.game] = value;
                ApplyOpacity((int)SpriteZone.game);
            }
        }

        public float DropZoneOpacity
        {
            get { return opacities[(int)SpriteZone.drop]; }
            set
            {
                opacities[(int)SpriteZone.drop] = value;
                ApplyOpacity((int)SpriteZone.drop);
            }
        }

        public float PieceOpacity
        {
            get { return opacities[(int)SpriteZone.piece]; }
            set
            {
                opacities[(int)SpriteZone.piece] = value;
                ApplyOpacity((int)SpriteZone.piece);
            }
        }

Y por último, necesitamos modificar el constructor para que cree el array de ImageAttributes y además inicialice:

Citarprivate TetrisSprite()
        {
            imageAttributes = new ImageAttributes[3];
            for (int i = 0; i < 3; i++)
            {
                imageAttributes = new ImageAttributes();
                ApplyOpacity(i);
            }
        }

2 métodos auxiliares de pintado más con vistas al futuro

Añadiremos 2 métodos más de pintado, uno de ellos será bastante típico pero aceptando un enumerado de la zona para poner la transparencia, y el otro será especificando el número de sprite a pintar:

Citarpublic void Draw(Graphics target, Bitmap source, Rectangle destRect, Rectangle srcRect, SpriteZone zone)
        {
            target.DrawImage(source, destRect, srcRect.X, srcRect.Y, srcRect.Width, srcRect.Height, GraphicsUnit.Pixel, imageAttributes[(int)zone]);
        }

        public void Draw(Graphics target, int spriteNumber, int left, int top, SpriteZone zone)
        {
            Rectangle destRect = new Rectangle(left, top, spriteRect.Width, spriteRect.Height);
            Draw(target, sprites[spriteNumber], destRect, spriteRect, zone);
        }

Probando el invento

Lo primero que haremos será añadir nuestro cubos.png al proyecto, pero lo vamos a hacer de una forma diferente. En lugar de cargarla directamente de disco duro hemos decidido que eso induce mucho a los malvados rippers de sprites, y que es mejor que vaya incrustado en el ejecutable. Es tan fácil como seguir estos pasos:
1) Crear una carpeta en el proyecto, la llamamos Resources

2) En esa carpeta dar a añadir elemento existente y seleccionar nuestro cubos.png
3) Seleccionamos el fichero de la imagen y en propiedades en Acción de compilación seleccionamos Recurso Incrustado



El proceso para cargar una imagen de un archivo de recursos incrustado es el siguiente:

CitarStream inputStream = GetType().Assembly.GetManifestResourceStream("aquí el nombre");
            Bitmap inputBitmap = new Bitmap(inputStream);
            inputStream.Close();
            // aquí va tu uso del bitmap....
            inputBitmap.Dispose();

Dónde pone "aquí el nombre" hay que colocar el nombre del recurso. ¿Y cuál es? Pues el nombre del proyecto y su estructura de carpetas y por último el nombre del fichero. Por ejemplo en nuestro caso el proyecto se llama "Tetris", la estructura de carpetas es "Resources" y el fichero es "cubos.png" así que el nombre del recurso será "Tetris.Resources.cubos.png".
Dónde pone "aquí va tu uso del bitmap" es dónde metemos el codigo para usarlo.

Vamos a nuestro Formulario principal y vamos a cambiarlo un poco:

CitarTetrisSprite sprite = TetrisSprite.Instance;

        public MainForm()
        {
            InitializeComponent();
            DoubleBuffered = true;
            Stream inputStream = GetType().Assembly.GetManifestResourceStream("Tetris.Resources.cubos.png");
            Bitmap inputBitmap = new Bitmap(inputStream);
            inputStream.Close();
            sprite.Initialize(inputBitmap, 8, true);
            inputBitmap.Dispose();
        }

        private void MainForm_Paint(object sender, PaintEventArgs e)
        {
            for (int i = 0; i < 8; i++)
            {
                sprite.Draw(e.Graphics, i, 30 + (i * sprite.SpriteRect.Width + 10), 30, SpriteZone.piece);
            }
            for (int i = 0; i < 8; i++)
            {
                sprite.Draw(e.Graphics, i, 30 + (i * sprite.SpriteRect.Width + 10), 80, SpriteZone.drop);
            }
        }

Hemos añadido un campo para la instancia de TetrisSprite, hemos hecho que TetrisSprite cargue los sprites del fichero, y en el evento Paint del formulario hacemos que pinte los 8 bitmaps, tanto con opacidad de pieza como con opacidad de drop.

El resultado:



El código fuente:
http://www.megaupload.com/?d=OPNP2K8X

Bill

9. Creando un Tetris: Piezas

Lo siguiente es definir las piezas del tetris, con la clase TetrisPiece. Esta clase nos debe asegurar que podamos hacer lo siguiente:

1- Tener todos los datos de pintado de una pieza, con lo que para cada pieza se guardarán sus posibles representaciones según el tipo de pieza y ángulo.
2- Guardar el identificador de pieza, rotación y posición en x e y dentro del espacio de juego.
3- Obtener una nueva pieza al azar y asignarle una posición inicial en función del ancho del espacio de juego, el alto del espacio de juego y el alto de la zona de drop.
4- Obtener el valor de la pieza en una determinada celda del espacio de juego, indicando para ello la posición de la pieza y la rotación
5- Obtener el valor de la pieza en una determinada celda en función de la rotación y posición actual.
6- Obtener la rotación siguiente de la pieza.

Constantes
Hacen falta 4 constantes que representarán al número de piezas existentes, la cantidad de rotaciones posibles de cada pieza, el ancho y el alto en celdas de cada pieza:

Citar#region · Constants ·

        public const int NumPieces = 7;

        public const int NumRotations = 4;

        public const int CellWidth = 4;

        public const int CellHeight = 4;

        #endregion

En nuestro caso hay 7 piezas, 4 rotaciones (0º, 90º, 180º y 270º) y las representamos en matrices de 4x4.

Definición de las piezas

Hay que definir las piezas, en formas de matrices 4x4, de las cuales sabemos que hay 7 piezas y 4 rotaciones, con lo cual hay un total de 7x4 = 28 matrices de las piezas. Además hay que saber lo que representa el valor de cada casilla:
- Un 0 representa que la pieza no tiene ningún cubo en esa casilla.
- Un valor distinto de 0 representa al número de sprite que hay que pintar.

En esta representación además tendremos en cuenta otra cosa: la altura de la zona de drop. Vamos a tener una zona de drop de 2 casillas de altura, así que vamos a intentar que la posición inicial de cada una de nuestras piezas quepa en 2 casillas de altura, así que hacemos revisión de nuestra hoja de piezas:



Vamos con la definición, sabemos que será una matriz de enteros de 4 dimensiones:

Citarpublic static int[,,,] PieceDefinition = new int[NumPieces,NumRotations,CellHeight,CellWidth] {
                {{{0, 0, 0, 0},{0, 1, 1, 0},{0, 1, 1, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{0, 1, 1, 0},{0, 1, 1, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{0, 1, 1, 0},{0, 1, 1, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{0, 1, 1, 0},{0, 1, 1, 0},{0, 0, 0, 0}}},
                {{{0, 0, 0, 0},{2, 2, 2, 2},{0, 0, 0, 0},{0, 0, 0, 0}},
                 {{0, 2, 0, 0},{0, 2, 0, 0},{0, 2, 0, 0},{0, 2, 0, 0}},
                 {{0, 0, 0, 0},{2, 2, 2, 2},{0, 0, 0, 0},{0, 0, 0, 0}},
                 {{0, 2, 0, 0},{0, 2, 0, 0},{0, 2, 0, 0},{0, 2, 0, 0}}},
                {{{0, 0, 0, 0},{0, 3, 3, 0},{3, 3, 0, 0},{0, 0, 0, 0}},
                 {{3, 0, 0, 0},{3, 3, 0, 0},{0, 3, 0, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{0, 3, 3, 0},{3, 3, 0, 0},{0, 0, 0, 0}},
                 {{3, 0, 0, 0},{3, 3, 0, 0},{0, 3, 0, 0},{0, 0, 0, 0}}},
                {{{0, 0, 0, 0},{4, 4, 0, 0},{0, 4, 4, 0},{0, 0, 0, 0}},
                 {{0, 4, 0, 0},{4, 4, 0, 0},{4, 0, 0, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{4, 4, 0, 0},{0, 4, 4, 0},{0, 0, 0, 0}},
                 {{0, 4, 0, 0},{4, 4, 0, 0},{4, 0, 0, 0},{0, 0, 0, 0}}},
                {{{5, 0, 0, 0},{5, 5, 5, 0},{0, 0, 0, 0},{0, 0, 0, 0}},
                 {{0, 5, 5, 0},{0, 5, 0, 0},{0, 5, 0, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{5, 5, 5, 0},{0, 0, 5, 0},{0, 0, 0, 0}},
                 {{0, 5, 0, 0},{0, 5, 0, 0},{5, 5, 0, 0},{0, 0, 0, 0}}},
                {{{0, 0, 6, 0},{6, 6, 6, 0},{0, 0, 0, 0},{0, 0, 0, 0}},
                 {{0, 6, 0, 0},{0, 6, 0, 0},{0, 6, 6, 0},{0, 0, 0, 0}},
                 {{0, 0, 0, 0},{6, 6, 6, 0},{6, 0, 0, 0},{0, 0, 0, 0}},
                 {{6, 6, 0, 0},{0, 6, 0, 0},{0, 6, 0, 0},{0, 0, 0, 0}}},
                {{{0, 0, 0, 0},{7, 7, 7, 0},{0, 7, 0, 0},{0, 0, 0, 0}},
                 {{0, 7, 0, 0},{7, 7, 0, 0},{0, 7, 0, 0},{0, 0, 0, 0}},
                 {{0, 7, 0, 0},{7, 7, 7, 0},{0, 0, 0, 0},{0, 0, 0, 0}},
                 {{0, 7, 0, 0},{0, 7, 7, 0},{0, 7, 0, 0},{0, 0, 0, 0}}}};

Cada fila representa a una de las posiciones de una pieza. Si colocamos una fila en vertical podremos entrever la pieza. Un ejemplo, vamos a pillar la pieza 6, y vamos a colocar cada fila en vertical con la anterior:

Citar{{{0, 0, 6, 0},
                  {6, 6, 6, 0},
                  {0, 0, 0, 0},
                  {0, 0, 0, 0}},
                 {{0, 6, 0, 0},
                  {0, 6, 0, 0},
                  {0, 6, 6, 0},
                  {0, 0, 0, 0}},
                 {{0, 0, 0, 0},
                  {6, 6, 6, 0},
                  {6, 0, 0, 0},
                  {0, 0, 0, 0}},
                 {{6, 6, 0, 0},
                  {0, 6, 0, 0},
                  {0, 6, 0, 0},
                  {0, 0, 0, 0}}},

Como vemos, son las 4 rotaciones de la pieza L.

Además de esas definiciones tendremos un array para indicarnos cuántas casillas en el eje Y empezando por abajo están vacías, para saber cómo colocarla en la zona de drop:

Citarprivate static readonly int[] PieceY = {1, 2, 1, 1, 2, 2, 1};


Propiedades

Necesitamos 4 propiedades para almacenar el Id, la rotación, la X y la Y actual de la pieza. En lugar de definir campos privados, y dado que solamente vamos a leer y escribir en ellos de forma convencional como si fuesen variables, vamos a utilizar una característica de C# muy útil que es que poniendo simplemente { get; set; } nos ahorramos el campo privado, y el compilador cuando genera el IL lo autocrea, de forma transparente para el programador. Así que la definición de propiedades nos quedaría:

Citar#region · Properties ·

        public int Id { get; set; }

        public int Rotation { get; set; }

        public int X { get; set; }

        public int Y { get; set; }

        #endregion

El azar

El azar en C# se maneja instanciando a la clase Random. La clase random permite dos constructores, uno de ellos sin pasar parámetros que crea azar total, y otro pasando una semilla que crea números pseudoaleatorios. En el caso de nuestra pieza nos vendrá mejor el segundo constructor. ¿Por qué? Porque tenemos intención de hacer el juego multijugador, y queremos que a ambos jugadores les vayan saliendo las mismas piezas, entonces lo mejor es obtener una semilla aleatoria al principio y utilizar la misma semilla para las piezas de ambos jugadores, así se obtendrán las mismas en el mismo orden, y la semilla se la pasaremos al constructor de la pieza. En el constructor, además, inicializaremos la pieza a vacía:

Citar#region · Fields ·

        private readonly Random _randomizer;

        #endregion

Citar#region · Constructor ·

        public TetrisPiece(int seed)
        {
            Id = -1;
            Rotation = 0;
            X = 0;
            Y = 0;
            _randomizer = (seed > 0 ? new Random(seed) : new Random());
        }

        #endregion


El operador ?

En el código del constructor hemos visto una línea un tanto peculiar. Me refiero a la siguiente:

Citar_randomizer = (seed > 0 ? new Random(seed) : new Random());

Se ha utilizado el operador ?. ¿Para qué sirve? El código anterior es equivalente al siguiente:

Citarif (seed >0)
            {
                _randomizer = new Random(seed);
            } else
            {
                _randomizer = new Random();
            }

La estructura del operador ? es la siguiente:

Citarcampo = (condición)?(valor true):(valor false);

y equivale a:

Citarif (condicion)
{
campo = (valor true);
}
else
{
campo = (valor false);
}

Es una manera de ahorrar tiempo y esfuerzo.

Calcular rotación siguiente

La rotación es un número del 0 al 3. El siguiente al 0 es el 1, el siguiente al 1 el 2, el siguiente al 2 el 3 y el siguiente al 3 es de nuevo el 0. En nuestro código hemos definido la constante NumRotations de forma que para calcular el siguiente valor sabemos que si la rotación actual es menor que la cantidad de rotaciones -1 entonces devuelve la rotación actual +1, en caso contrario devuelve 0.
Un ejemplo claro, sabemos que la cantidad de rotaciones es 4, entonces si la rotación actual es menor de (4-1 = 3) entonces devuelve la rotación actual+1, pero si vale 3 o superior devuelve 0. Esto se traduce en:

Citarpublic int NextRotation()
        {
            return (Rotation < (NumRotations - 1) ? Rotation + 1 : 0);
        }

Coger una pieza nueva

Tan sencillo como coger un número aleatorio entre 0 y NumPieces como Id de la pieza e inicializar la rotación a 0... si no fuese por el cálculo de la posición. En el eje X sabemos que tenemos que colocarla centrada, y para eso la tenemos que colocar el centro de la pieza en el centro del espacio de juego, así que dividimos el ancho del espacio de juego entre 2 y le restamos el ancho de la pieza entre 2. Además tenemos que hacer que quede bien situada en la zona de drop, en función de la altura del espacio de juego, la altura del drop, la altura de las piezas y las líneas vacías de la pieza por abajo:

Citarpublic void NewPiece(int spaceWidth, int spaceHeight, int spaceDropHeight)
        {
            Id = _randomizer.Next(NumPieces);
            Rotation = 0;
            X = spaceWidth / 2 - CellWidth / 2;
            Y = spaceHeight - spaceDropHeight - 1 + CellHeight - PieceY[Id];
        }

Calcular el valor de una celda de la pieza en una determinada posición del espacio de juego:

Dada una posición del espacio de juego (x,y), una posición de la pieza (pieceX, pieceY) y una rotación de la pieza. Si la pieza está recién inicializada y por tanto su identificador es -1, se devuelve 0.
En caso contrario se calcula la celda dentro de la matriz, y si la celda cae en los límites de la matriz de la pieza se devuelve su valor, en caso contrario 0:


Citarpublic int PieceValueAt(int x, int y, int rotation, int pieceX, int pieceY)
        {
            if (Id <= -1)
            {
                return 0;
            }
            int cellX = x - pieceX;
            int cellY = pieceY - y;

            if ((cellX >= 0) && (cellX < CellWidth) && (cellY >= 0) && (cellY < CellHeight))
            {
                return PieceDefinition[Id, rotation, cellY, cellX];
            }
            return 0;
        }

Hacemos una sobrecarga del método para contemplar el caso de una pieza con su propia rotación y posición:

Citarpublic int PieceValueAt(int x, int y)
        {
            return PieceValueAt(x, y, Rotation, X, Y);
        }

Mostrar algo en el principal:

En el MainForm vamos a tratar de que muestre todas las piezas posibles, para ello este será el código del MainForm:

Citarpublic partial class MainForm : Form
    {
        private readonly TetrisSprite _sprite = TetrisSprite.Instance;
        private readonly TetrisPiece _piece;

        public MainForm()
        {
            Random seedGenerator = new Random();
            InitializeComponent();
            DoubleBuffered = true;
            Stream inputStream = GetType().Assembly.GetManifestResourceStream("Tetris.Resources.cubos.png");
            if (inputStream != null)
            {
                Bitmap inputBitmap = new Bitmap(inputStream);
                inputStream.Close();
                _sprite.Initialize(inputBitmap, 8, true);
                inputBitmap.Dispose();
            }
            _piece = new TetrisPiece(seedGenerator.Next());
        }

        private void DrawPiece(Graphics graphics, int id, int rotation, int x, int y)
        {
            _piece.Id = id;
            _piece.Rotation = rotation;
            _piece.X = 0;
            _piece.Y = 3;
            for (int cellY = TetrisPiece.CellHeight-1; cellY >=0; cellY--)
            {
                for (int cellX = 0; cellX < TetrisPiece.CellWidth; cellX++)
                {
                    _sprite.Draw(graphics, _piece.PieceValueAt(cellX, TetrisPiece.CellHeight -1 - cellY),
                        x+cellX*_sprite.XIncrement, y+cellY*_sprite.YIncrement-cellX*_sprite.YIncrementForX, SpriteZone.Piece);
                }
            }
        }

        private void MainForm_Paint(object sender, PaintEventArgs e)
        {
            int y = 10;
            for (int id = 0; id < TetrisPiece.NumPieces; id++)
            {
                int x = 10;
                for (int rotation = 0; rotation < TetrisPiece.NumRotations; rotation++)
                {
                    DrawPiece(e.Graphics,id, rotation, x, y);
                    x += _sprite.XIncrement * TetrisPiece.CellWidth + TetrisPiece.CellWidth+10;
                }
                y += _sprite.YIncrement * TetrisPiece.CellHeight + TetrisPiece.CellHeight + 10;
            }
        }
    }

El resultado que veríamos sería este:


Pero además vamos a probar con piezas que no sean en isométrica. Para ello añadir el siguiente archivo de recursos igual que hicimos con el primero:


Dónde se carga el archivo de recursos cambiarlo para adecuarlo al nuevo nombre:

CitarStream inputStream = GetType().Assembly.GetManifestResourceStream("Tetris.Resources.cubos3.png");

Y en la inicialización del _sprite indicar que se trata de sprites planos y no en isométrica:

Citar_sprite.Initialize(inputBitmap, 8, false);

El resultado es el siguiente:


Código fuente pendiente de subida.

Bill

#274
10. Creando un Tetris: Espacio de juego

Introducción

Tenemos la forma de pintar en pantalla y las diferentes casillas que hay para los diferentes colores de pieza, también tenemos la representación de una pieza en concreto, solamente falta el espacio en el que se pinta. El espacio de juego se verá de la siguiente forma:



La zona que hay con transparencia es el drop, por el que caen las piezas. Si una pieza no puede bajar más, y alguna de sus casillas ocupadas (no las transparentes) está en la zona de drop, entonces se termina el juego.

El espacio de juego nos implicará la creación de la clase TetrisSpace.

Campos

Si atendemos a la figura anterior, vemos que al menos necesitamos 5 campos para guardar características numéricas del espacio de juego:
- left : posición izquierda en pixels en el canvas a pintar
- top : posición de altura en pixels con respecto al más inferior de los cubos del espacio de juego.
- width: ancho en casillas del espacio de juego
- height: altura en casillas del espacio de juego
- dropHeight: altura en casillas de la zona de drop

Además necesitaremos:
- Una matriz de enteros para representar a las casillas del espacio, un 0 representa una casilla vacía, en caso contrario el valor se corresponde al índice del sprite.
- La instancia de TetrisSprite para poder pintar. Como es un singleton, la podemos coger directamente.
- Una instancia de TetrisPiece para representar a la pieza flotante actual en el espacio de juego.

Citar#region · Fields ·

       private readonly int[,] _space;

       private readonly TetrisSprite _sprites = TetrisSprite.Instance;

       private readonly TetrisPiece _piece;

       private readonly int _left;

       private readonly int _top;

       private readonly int _width;

       private readonly int _height;

       private readonly int _dropHeight;

       #endregion

Constructor

En el constructor simplemente se inicializarán los campos con los valores pasados como parámetro (left, top, width, height y dropHeight) y además existirá un parámetro para la semilla aleatoria para la instanciación de TetrisPiece. Se instanciará TetrisPiece y la matriz.

Citarpublic TetrisSpace(int left, int top, int width, int height, int dropHeight, int seed)
       {
           _piece = new TetrisPiece(seed);
           _left = left;
           _top = top;
           _width = width;
           _height = height;
           _dropHeight = dropHeight;
           _space = new int[_width, _height];
       }

Iniciando TetrisSpace
Para iniciar la instancia de TetrisSpace hacen falta dos cosas:
- Limpiar la matriz de casillas
- Sacar una pieza nueva

Para ello creamos métodos privados para limpiar (Clear) y para sacar una pieza nueva (NewPiece) y un método público para iniciar la instancia (Start).

El Clear es sencillo: recorrer cada casilla e inicializarla a 0, y para eso utilizamos dos for anidados.

Citarprivate void Clear()
       {
           for (int y = 0; y < _height; y++)
           {
               for (int x = 0; x < _width; x++)
               {
                   _space[x, y] = 0;
               }
           }
       }

El NewPiece consiste en invocar al método NewPiece de la instancia de TetrisPiece dándole como parámetros el ancho, el alto y el alto de la zona de drop de nuestro espacio de juego:

Citarprivate void NewPiece()
       {
           _piece.NewPiece(_width, _height, _dropHeight);
       }

Por último el Start es sencillamente la invocación de los dos anteriores:

Citarpublic void Start()
       {
           Clear();
           NewPiece();
       }

Métodos auxiliares para saber dónde cae una casilla
Una casilla (x,y) puede caer fuera del espacio de juego o dentro del espacio de juego. Además, si está dentro del espacio de juego, puede que esté o no en la zona de drop. Vamos a necesitar conocer dónde cae una casilla, y para ello necesitaremos dos métodos privados, uno para decirnos si una casilla cae en el espacio de juego o si cae en la zona de drop:

Para saber si cae en la zona de juego simplemente hay que comprobar que la coordenada (x,y) caiga entre el punto (0,0) y el punto (width,height). Para saber si cae en zona de drop es comprobar si cae en el espacio de juego y además que su coordenada y sea mayor o igual que la altura menos la altura de drop.

Citarprivate bool IsSpaceZone(int x, int y)
       {
           return ((x >= 0) && (x < _width) && (y >= 0) && (y < _height));
       }

       private bool IsDropZone(int x, int y)
       {
           return (IsSpaceZone(x, y) && (y >= (_height - _dropHeight)));
       }

Transformando la coordenada de celda a rectángulo de pintado del sprite
Nos hace falta un Método que dada una coordenada (x,y) de una celda nos diga el Rect de pintado de esa celda, que se saca a partir del left, el top y los siguientes datos de sprite: ancho de sprite, alto de sprite, incremento en x, incremento en y, incremento en y por x.

Citarprivate Rectangle CellRectangle(int x, int y)
       {
           if (IsSpaceZone(x, y))
           {
               return new Rectangle(_left + x * _sprites.XIncrement, _top - y * _sprites.YIncrement
                   - x * _sprites.YIncrementForX, _sprites.SpriteRect.Width, _sprites.SpriteRect.Height);
           }
           return new Rectangle(0, 0, 0, 0);
       }

Pintando el espacio de juego

Ya tenemos todos los métodos auxiliares que necesitamos para el pintado. Para el pintado utilizaremos un método Draw que aceptará como parámetro el canvas como instancia de Graphics. El pintado será tan sencillo como recorrer cada una de las casillas del espacio de juego en el orden inidicado previamente (de abajo a arriba y de izquierda a derecha), y para cada casilla ver si está ocupada por un trozo de nuestra pieza actual, en cuyo caso pintaremos su valor y la zona de pitando será SpriteZone.Piece. Si no está ocupada por un trozo de nuestra pieza actual, pintaremos lo que haya en la matriz del espacio de juego, y además si es zona de de Drop la zona será SpriteZone.Drop, y si no lo es será SpriteZone.Game. Por último calcularemos el rectángulo en el que debemos pintar e invocaremos al método Draw de nuestra instancia de TetrisSprite:

Citarpublic void Draw(Graphics drawZone)
       {
           for (int y = 0; y < _height; y++)
           {
               for (int x = 0; x < _width; x++)
               {
                   int actualValue = _piece.PieceValueAt(x, y);
                   if (actualValue <= 0)
                   {
                       actualValue = _space[x, y];
                   }
                   SpriteZone zone;
                   if (actualValue == 0)
                   {
                       zone = IsDropZone(x, y) ? SpriteZone.Drop : SpriteZone.Game;
                   }
                   else
                   {
                       zone = SpriteZone.Piece;
                   }
                   Rectangle cellRect = CellRectangle(x, y);
                   _sprites.Draw(drawZone, actualValue, cellRect.X, cellRect.Y, zone);
               }
           }
       }

Probando lo que hay hasta ahora

En el MainForm utilizaremos la siguiente implementación:

Citarprivate readonly TetrisSprite _sprite = TetrisSprite.Instance;
       private readonly TetrisSpace _space;

       public MainForm()
       {
           Random seedGenerator = new Random();
           InitializeComponent();
           DoubleBuffered = true;
           Stream inputStream = GetType().Assembly.GetManifestResourceStream("Tetris.Resources.cubos.png");
           if (inputStream != null)
           {
               Bitmap inputBitmap = new Bitmap(inputStream);
               inputStream.Close();
               _sprite.Initialize(inputBitmap, 8, true);
               inputBitmap.Dispose();
           }
           _space = new TetrisSpace(30,250,10,22,2,seedGenerator.Next());
           _space.Start();
       }

       private void MainForm_Paint(object sender, PaintEventArgs e)
       {
           _space.Draw(e.Graphics);
       }

Esto nos pintará el espacio de juego iniciado, con la primera pieza sacada:



Comprobando que una pieza se puede mover
Una de las partes importantes es calcular si una pieza se puede mover a la izquierda, derecha, abajo o  rotar. Para esto necesitaremos un método en el que le demos una rotación de la pieza y una posición (x,y) y compruebe si la pieza se puede poner en esa rotación y posición. Consideremos que una pieza se puede poner en una posición y rotación si cumple las siguientes condiciones:
- Ninguna de sus casillas no transparentes cae fuera del espacio de juego, excepto si es por encima de la zona de drop.
- Ninguna de sus casillas no transparentes cae encima de una casilla ocupada del espacio de juego.

Citarprivate bool TestPiece(int rotation, int left, int top)
       {
           for (int y = -1; y < _height; y++)
           {
               for (int x = -1; x <= _width; x++)
               {
                   if (_piece.PieceValueAt(x, y, rotation, left, top) == 0) continue;
                   if ((y <0 ) || (x <0) || (x >= _width))
                   {
                       return false;
                   }
                   if (_space[x, y] != 0)
                   {
                       return false;
                   }
               }
           }
           return true;
       }

Aplicando una pieza al espacio de juego

Cuando una pieza llega al tope de bajada y no puede bajar más, se aplica al espacio de juego, es decir, sus casillas se graban en la matriz del espacio de juego para poder pedir otra pieza. Además, al aplicar, debemos borrar las líneas que se hayan completado, para lo que necesitaremos métodos auxiliares para comprobar si una línea está completa, para elimitar una línea y para eliminar todas las líneas completas:

Citarprivate bool IsCompleteLine(int line)
       {
           for (int x = 0; x < _width; x++)
           {
               if (_space[x, line] == 0)
               {
                   return false;
               }
           }
           return true;
       }

       private void RemoveLine(int line)
       {
           for (int y = line; y < _height - _dropHeight; y++)
           {
               for (int x = 0; x < _width; x++)
               {
                   _space[x, y] = _space[x, y + 1];
               }
           }
       }

       private void RemoveCompleteLines()
       {
           int i = 0;
           while (i < (_height - _dropHeight))
           {
               if (IsCompleteLine(i))
               {
                   RemoveLine(i);
               }
               else
               {
                   i++;
               }
           }
       }

Para aplicar la pieza al espacio de juego se recorren las casillas del espacio de juego y si hay alguna que tenga un trozo de la pieza se aplica a la matriz:

Citarprivate void ApplyPiece()
       {
           for (int y = 0; y < _height; y++)
           {
               for (int x = 0; x < _width; x++)
               {
                   int actualValue = _piece.PieceValueAt(x, y);
                   if (actualValue != 0)
                   {
                       _space[x, y] = actualValue;
                   }
               }
           }
           RemoveCompleteLines();
       }

Realizando acciones sobre el espacio de juego

Hay 5 acciones que se pueden realizar sobre el espacio de juego con respecto a su pieza:
- Rotar la pieza
- Mover la pieza a la izquierda
- Mover la pieza a la derecha
- Mover la pieza hacia abajo
- Dejar caer la pieza.

Para representar a estas 5 acciones utilizaremos un tipo enumerado:

Citarpublic enum TetrisAction
   {
       Down,
       Rotate,
       Right,
       Left,
       Drop
   }

Además estas acciones darán un resultado entre los siguientes:
- La pieza se movió correctamente
- La pieza no se movió, sino que se aplicó al espacio de juego, se limpiaron las líneas completas y se pidió una pieza nueva
- La pieza no se pudo mover
- La pieza no se pudo mover y además alguna de sus casillas está en la drop zone.

De nuevo emplearemos un tipo enumerado:

Citarpublic enum PieceMoveResult
   {
       Moved,
       Applied,
       NotMoved,
       DropZone
   }

Las acciones de mover a la derecha, mover a la izquierda y rotar son sencillas, simplemente se prueba si se puede colocar la pieza en la nueva posición, si hay éxito se cambia la posición o rotación de la pieza y se devuelve PieceMoveResult.Moved, en caso contrario se devuelve PieceMoveResult.NotMoved.

La acción de bajar es más complicada, si se pudo mover se devuelve PieceMoveResult.Moved, en caso contrario si está en la zona de drop se devuelve PieceMoveResult.DropZone, y si no está en la DropZone se aplica la pieza al espacio de juego, se pide una nueva pieza y se devuelve PieceMoveResult.Applied.

Por último está la acción de dejar caer, que nos llevará a hacer la función recursiva porque lo que haremos será que mientras se pueda mover la pieza hacia abajo, se haga, y devuelve el último resultado que devuelva utilizar la acción TetrisAction.Down que no devuelva PieceMoveResult.Moved.

Al grano:

Citarpublic PieceMoveResult DoAction(TetrisAction action)
       {
           if (action == TetrisAction.Drop)
           {
               PieceMoveResult result = DoAction(TetrisAction.Down);
               while (result == PieceMoveResult.Moved)
               {
                   result = DoAction(TetrisAction.Down);
               }
               return result;
           }
           int rotation = _piece.Rotation;
           int x = _piece.X;
           int y = _piece.Y;
           switch (action)
           {
               case TetrisAction.Down:
                   y -= 1;
                   break;
               case TetrisAction.Left:
                   x -= 1;
                   break;
               case TetrisAction.Right:
                   x += 1;
                   break;
               case TetrisAction.Rotate:
                   rotation = _piece.NextRotation();
                   break;
           }
           if (TestPiece(rotation, x, y))
           {
               _piece.Rotation = rotation;
               _piece.X = x;
               _piece.Y = y;
               return PieceMoveResult.Moved;
           }
           if (action == TetrisAction.Down)
           {
               if (IsDropZone(_piece.X, _piece.Y))
               {
                   return PieceMoveResult.DropZone;
               }
               ApplyPiece();
               NewPiece();
               return PieceMoveResult.Applied;
           }
           return PieceMoveResult.NotMoved;
       }

Input con el usuario en el MainForm para probarlo

En el formulario principal responderemos a eventos de teclado (KeyDown) de forma que realicemos acciones sobre el TetrisSpace. Para ello añadimos el evento KeyDown del formulario con la siguiente implementación:

Citarprivate void MainForm_KeyDown(object sender, KeyEventArgs e)
       {
           switch (e.KeyCode)
           {
               case Keys.Space:
                   _space.DoAction(TetrisAction.Drop);
                   break;
               case Keys.Up:
                   _space.DoAction(TetrisAction.Rotate);
                   break;
               case Keys.Down:
                   _space.DoAction(TetrisAction.Down);
                   break;
               case Keys.Left:
                   _space.DoAction(TetrisAction.Left);
                   break;
               case Keys.Right:
                   _space.DoAction(TetrisAction.Right);
                   break;
           }
           Invalidate();
       }


Ahora ya podremos mover las piezas y ver el resultado.

Pendiente de subida de vídeo.
Pendiente de subida de código.

Bill

#275
11. Creando un Tetris: Motor del juego

Introducción
Ya tenemos todo lo necesario para construir nuestro tetris. Solamente nos falta el motor, que para un sólo jugador, sin puntos ni música ni nada, es bastante sencillo porque casi toda su funcionalidad nos la da TetrisSpace.

Campos

Hacen falta tres campos para el motor:
- Un SolidBrush con un color para pintar el fondo de la pantalla.
- Una instancia de TetrisSpace.
- Un timer para cada cierto tiempo efectuar la acción de mover la pieza hacia abajo.

Citar#region · Fields ·

       private readonly SolidBrush _backgroundBrush = new SolidBrush(Color.White);

       private TetrisSpace _space;

       private readonly Timer _tetrisTimer = new Timer();

       #endregion

Constructor
En el constructor de la clase cambiaremos el color del SolidBrush al que nos pasen por parámetro. También cogerá como parámetro el Bitmap con los sprites, el número de sprites y si es isométrica, inicializando la instancia de TetrisSprite con dichos parámetros. Por último, hará que el timer invoque a DoOnTimer en su evento Elapsed, para lo que necesitamos declarar un método DoOnTimer que por ahora dejaremos vacío:

Citarprivate void DoOnTimer(Object sender, EventArgs e)
       {
       }

Citar#region · Constructor ·

       public TetrisEngine(Color backgroundColor, Bitmap srcCubeBitmap, int numCubes, bool isIsometric)
       {
           _backgroundBrush.Color = backgroundColor;
           TetrisSprite.Instance.Initialize(srcCubeBitmap, numCubes, isIsometric);
           _tetrisTimer.Elapsed += DoOnTimer;
       }

       #endregion

Inicialización
Aparte de la creación está la inicialización del espacio de juego:

Citarpublic void Initialize(int left, int top, int width, int height, int dropHeight)
       {
           Random seedRandomize = new Random();
           _space = new TetrisSpace(left, top, width, height, dropHeight, seedRandomize.Next());

       }

Declarando un evento para actualizaciones

El motor del juego necesitará informar de cuándo se ha producido un cambio en el juego y necesita ser repintado. Para ello declararemos un evento OnUpdate:

Citar#region · Events ·

       public event EventHandler OnUpdate;

       #endregion

Iniciando y parando el juego
Habrá dos métodos públicos para iniciar y para parar el juego (Start y Stop). El Start llama al Start de TetrisSpace, invoca al evento OnUpdate e inicia el timer de juego. El stop simplemente para el timer de juego:

Citarpublic void Start()
       {
           _space.Start();
           if (OnUpdate != null)
           {
               OnUpdate(this, null);
           }
           _tetrisTimer.Interval = 500;
           _tetrisTimer.Start();
       }

       public void Stop()
       {
           _tetrisTimer.Stop();
       }

El dibujado

Ahora necesitamos dibujar, además del espacio de juego, el fondo, para lo que utilizamos el método FillRectangle. Definimos nuestro método de pintado:

Citarpublic void Draw(Graphics graphicSpace)
       {
           graphicSpace.FillRectangle(_backgroundBrush, graphicSpace.ClipBounds);
           _space.Draw(graphicSpace);
       }

Realizando acciones

Ahora hay que realizar acciones, igual que hacíamos en el TetrisSpace. Para ello tenemos un método privado genérico para realizar cualquier acción, y métodos independientes para cada acción que invocan a este:

Citarprivate void DoAction(TetrisAction action)
       {
           if (_space.DoAction(action) == PieceMoveResult.DropZone)
           {
               Stop();
           }
           if (OnUpdate != null)
           {
               OnUpdate(this, null);
           }
       }
       public void MoveDown()
       {
           DoAction(TetrisAction.Down);
       }

       public void MoveLeft()
       {
           DoAction(TetrisAction.Left);
       }
       
       public void MoveRight()
       {
           DoAction(TetrisAction.Right);
       }

       public void Rotate()
       {
           DoAction(TetrisAction.Rotate);
       }

       public void Drop()
       {
           DoAction(TetrisAction.Drop);
       }

Solamente nos queda un último detalle con esto: que cuando el Timer salte, se llame a MoveDown():

       private void DoOnTimer(Object sender, EventArgs e)
       {
           MoveDown();  
       }

Adaptación del Formulario Principal

En el formulario principal ya no hará falta ni TetrisSpace ni TetrisSprite, simplemente el TetrisEngine, en el cual nos vamos a suscribir a su evento OnUpdate mediante un método DoOnUpdate que llamará al Invalidate() para repintar el canvas:
Citar
       private readonly TetrisEngine _engine;

       public MainForm()
       {
           InitializeComponent();
           DoubleBuffered = true;
           Stream inputStream = GetType().Assembly.GetManifestResourceStream("Tetris.Resources.cubos.png");
           if (inputStream != null)
           {
               Bitmap inputBitmap = new Bitmap(inputStream);
               inputStream.Close();
               _engine = new TetrisEngine(Color.Wheat,inputBitmap,8,true);
               inputBitmap.Dispose();
               _engine.OnUpdate += DoOnUpdate;
               _engine.Initialize(30, 250, 10, 22, 2);
           }
       }

       private void DoOnUpdate(object sender, EventArgs e)
       {
           Invalidate();
       }

       private void MainForm_Paint(object sender, PaintEventArgs e)
       {
           _engine.Draw(e.Graphics);
       }

En el OnKeyDown responderemos a los eventos de teclado invocando a los métodos de movimiento de la instancia del engine:

Citarprivate void MainForm_KeyDown(object sender, KeyEventArgs e)
        {
            switch (e.KeyCode)
            {
                case Keys.Enter:
                    _engine.Start();
                    break;
                case Keys.Space:
                    _engine.Drop();
                    break;
                case Keys.Up:
                    _engine.Rotate();
                    break;
                case Keys.Down:
                    _engine.MoveDown();
                    break;
                case Keys.Left:
                    _engine.MoveLeft();
                    break;
                case Keys.Right:
                    _engine.MoveRight();
                    break;
            }
        }

Conclusión

Ahora mismo ya tendríamos el juego funcionando. Cuando arrancamos aparece el tablero vacío, con la tecla Enter iniciamos una nueva partida. ¿Qué nos queda? Los detalles: sonido, menú, puntuación, contador de líneas, imagen de pieza siguiente, dos jugadores hot-site y dos jugadores on-line.

Iremos "tuneando" el juego poco a poco, pero en principio ya funciona ;)

Thylzos


Gracias freyi *.*


Cita de: Gambit en 26 de Enero de 2010, 10:25
Follar cansa. Comprad una xbox 360, nunca le duele la cabeza, no discute, no hay que entenderla, la puedes compartir con tus amigos...

Bill

Cita de: Thylzos en 05 de Mayo de 2010, 18:32
Mola o.o.

¡Más!

Tranqui, que ahora subo el código y vídeo ;)
De hecho, me lo has recordado. ¡Voy!

maikolf

Cita de: Gambit en 05 de Mayo de 2010, 18:35
Cita de: Thylzos en 05 de Mayo de 2010, 18:32
Mola o.o.

¡Más!

Tranqui, que ahora subo el código y vídeo ;)
De hecho, me lo has recordado. ¡Voy!

podrias subir el codigo...por favor????

Crosher

Cita de: Ningüino Flarlarlar en 17 de Enero de 2019, 16:39
Crosher es la nueva sensación del foro.

Sorry but you are not allowed to view spoiler contents.

Cita de: Deke en 14 de Junio de 2011, 00:08
Es como si te empeñases en jugar al Twister siendo daltónico.

www.latragaperras.blogspot.es

Últimos mensajes