Recreando el juego del Ping Pong con Windows Forms (completo)

Anteriormente, había escrito una entrada muy básica sobre la creación de un juego tipo Ping Pong con Windows Forms en C# y Visual Basic, en esta ocasión, escribo una versión completamente funcional en Windows Forms.

Creando y configurando el proyecto

Lo primero que haremos, será crear el proyecto, éste lo crearemos en el apartado Visual C# -> Windows Classic Desktop -> Windows Forms App (.NET Framework), de la siguiente forma:

Creando un proyecto Windows Forms
Creando un proyecto Windows Forms

Configurando el formulario

Colocaremos los elementos principales en nuestro formulario.

Le ponemos un fondo negro al formulario a través de la propiedad BackColor.

Colocamos 4 PictureBox y les asignamos lo siguiente:

NameSize
pbPlayer165, 160
pbPlayer265, 160
pbTitleScreenN/A
pbBall35, 35

Colocamos 2 elementos Label, y les hacemos los siguientes ajustes:

NameText
lblScore10
lblScore20
N/A

Al final, debemos tener algo así:

Interfaz Gráfica del Ping Pong
Interfaz Gráfica del Ping Pong

No te preocupes mucho en estos momentos por las posiciones, eso lo haremos bajo código.

Definiendo el tamaño del contenedor

Definiremos 2 constantes como parte de la clase, una llamada ScreenWidth, y otra llamada ScreenHeight, de la siguiente forma:

    public partial class Form1 : Form
    {
        private const int ScreenWidth = 1024;
        private const int ScreenHeight = 768;
        public Form1()
        {
            InitializeComponent();
        }
    }

Dentro del constructor, definiremos el nuevo tamaño de nuestra ventana, a través de la propiedad ClientSize, a la cual le pasaremos los parámetros definidos previamente.

        public Form1()
        {
            InitializeComponent();
            ClientSize = new Size(ScreenWidth, ScreenHeight);
        }

Con esto, si ejecutamos la aplicación, tendremos una ventana definida en base a los vales asignados a las variables ScreenWidth y ScreenHeight.

Cargando los Sprites

Nos interesa ahora, mostrar las imágenes en los PictureBox correspondientes. Para esto, crearemos una nueva clase llamada “GameItem”, la cual será la encargada de gestionar la velocidad, la posición y la imagen de un elemento “jugable” en la pantalla, y la codificaremos de la siguiente forma:

    public class GameItem
    {
        public Point Position { get; set; }
        public Point Velocity { get; set; }
        public PictureBox Texture { get; set; }
        public Point Origin
        {
            get
            {
                return new Point(Texture.Width / 2, Texture.Height / 2);
            }
        }
        public void Draw()
        {
            this.Texture.Location = new Point(this.Position.X - this.Origin.X,
                this.Position.Y - this.Origin.Y);
        }
    }

De igual forma, creamos otra clase llamada BallItem, la cual heredará de nuestra clase base “GameItem”, y la cual tendrá un método específico para actualizar la posición de nuestra pelota.

[quads id=3]

    public class BallItem : GameItem
    {
        public void Update()
        {
            this.Position = new Point(this.Position.X + this.Velocity.X,
                this.Position.Y + this.Velocity.Y);
        }
    }

Regresando a la clase del formulario, creamos 3 variables, 2 del tipo GameItem, y 1 del tipo BallItem.

        private const int ScreenWidth = 1024;
        private const int ScreenHeight = 768;
        private GameItem _player1;
        private GameItem _player2;
        private BallItem _ball;

Dentro del constructor, nos suscribimos al evento Load del formulario y también llamamos a un método que se llamará “Initialize”. Como parte del evento Load, llamaremos a un método llamado “LoadGraphicsContent”, el cual definimos de la siguiente forma:

        public Form1()
        {
            InitializeComponent();
            ClientSize = new Size(ScreenWidth, ScreenHeight);
            Initialize();
            this.Load += Form1_Load;
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            LoadGraphicsContent();
        }
        private void LoadGraphicsContent()
        {
            pbPlayer1.Load("Paddle1.png");
            _player1.Texture = pbPlayer1;


            pbPlayer2.Load("Paddle2.png");
            _player2.Texture = pbPlayer2;

            pbBall.Load("Ball.png");
            _ball.Texture = pbBall;
        }
        private void Initialize()
        {            
            _player1 = new GameItem();

            _player2 = new GameItem
            {
                Position = new Point(ScreenWidth - 3, ScreenHeight / 2)
            };
            _ball = new BallItem
            {
                Velocity = new Point(2, 5)
            };
        }

Antes de ejecutar el proyecto, debemos descargar los sprites. Dichos sprites, hay que descomprimirlos, y deben quedar en la carpeta de Debug.

Colocando los sprites
Colocando los sprites

Con esto, ya tendremos nuestros sprites cargados en el formulario.

Sprites cargados
Sprites cargados

Detectar mouse y dibujar el Jugador 1

Antes de continuar, crearemos 5 regiones:

  • GamePlay Methods
  • Events
  • Engine Methods
  • Mechanics
  • Collisions
        #region GamePlay Methods
        #endregion
        #region Events
        #endregion
        #region Engine Methods
        #endregion
        #region Mechanics
        #endregion
        #region Collisions
        #endregion

Colocaremos el código del evento de carga del formulario en la región de eventos, Initalize y LoadGraphicsContent dentro de Engine Methods.

Agregamos 2 timers como parte de la interfaz principal, y les asignamos lo siguiente:

NameEnabledInterval
UpdateTimerTrue16
DrawTimerTrue16

Tenemos que crearles su respectivo manejador de eventos para el evento Tick, los cuales llevaremos a la región de eventos para tener el código ordenado. Dentro del manejador de eventos del evento click del control UpdateTimer, hay que llamar un método no definido aún llamado UpdateScene, el cual contendrá la lógica para llevar a cabo la actualización de la interfaz gráfica. UpdateScene debe ir en la región EngineMethods.

        private void UpdateTimer_Tick(object sender, EventArgs e)
        {
            UpdateScene();
        }

Definiremos el método UpdateScene dentro de la región Engine Methods, y desde ahí llamaremos un nuevo método no definido llamado UpdatePlayer.

        private void UpdateScene()
        {
            UpdatePlayer();
        }

Dentro de la región Mechanics, definiremos el nuevo método UpdatePlayer, en donde definiremos el código, en primer lugar para controlar al jugador 1. Comenzamos por definir la posición en X constante que tendrá nuestro sprite en el escenario, esto es, debido a que un paddle sólo puede moverse de arriba hacia abajo, por lo tanto, la posición en X siempre será la misma. Por otro lado, tomaremos el valor en Y, de acuerdo a la posición del puntero del mouse. Posteriormente, crearemos una nueva posición de acuerdo a los valores obtenidos. Finalmente, hacemos comprobaciones para que nuestro paddle no se salga de los límites del escenario.

        private void UpdatePlayer()
        {
            int playerX = 0 + 30;
            int playerY = PointToClient(MousePosition).Y;
            _player1.Position = new Point(playerX, playerY);

            if (_player1.Texture.Bottom >= ScreenHeight)
            {
                _player1.Position = new Point(playerX, ScreenHeight - _player1.Origin.Y - 1);
            }
            else if (_player1.Texture.Top <= 0)
            {
                _player1.Position = new Point(playerX, _player1.Origin.Y + 1);
            }
        }

Por último, debemos de redibujar el control en su nueva posición, por lo que tenemos que codificar dentro del manejador de eventos de DrawTimer_Tick, un nuevo método llamado DrawScene.

        private void DrawTimer_Tick(object sender, EventArgs e)
        {
            DrawScene();
        }

DrawScene estará definido dentro de Engine Methods, y contendrá una llamada al método Draw del sprite correspondiente.

        private void DrawScene()
        {
            _player1.Draw();
        }

Si ejecutamos, tendremos el paddle del jugador 1 en acción.

Detectar teclado y dibujar el Jugador 2

Llevar a cabo la detección de las teclas en Windows Forms, es más fácil en estos días. Para hacer esto, hay que utilizar 2 bibliotecas que no son agregadas por defecto: “PresentationCore” y “WindowsBase”. Con esto, podremos implementar la lógica en el método UpdatePlayer, para detectar si una tecla de nuestra preferencia ha sido tecleada, en nuestro caso, utilizaremos las teclas ‘S' y ‘W', posteriormente, validamos si el paddle del jugador 2 se encuentra dentro del escenario. También debemos definir una variable a nivel de clase llamada “_currentY”, del tipo int.

El código para realizar la comprobación, es el siguiente:

            if (Keyboard.IsKeyDown(Key.S))
            {
                if(_player2.Texture.Bottom >= ScreenHeight)
                {
                    _currentY -= 0;
                }
                else
                {
                    _currentY += 30;
                }
                _player2.Position = new Point(ScreenWidth - 30, _currentY);
            }
            else if (Keyboard.IsKeyDown(Key.W))
            {
                if (_player2.Texture.Top <= 0)
                {
                    _currentY += 0;
                }
                else
                {
                    _currentY -= 30;
                }

                int player2X = ScreenWidth - 30;
                int player2Y = _currentY;
                _player2.Position = new Point(player2X, player2Y);

            }

Por último, no debemos olvidar actualizar el dibujado del paddle, en el método DrawScene.

        private void DrawScene()
        {
            _player1.Draw();
            _player2.Draw();
        }

Moviendo la pelota

Para mover la pelota, debemos definir 3 nuevos métodos dentro de la región “Mechanics”, los cuales serán: “ResetBall”, “GenerateBallX” y “GenerateBallY”.

        private void ResetBall()
        {
            _level = 7;
            int velocityY = GenerateBallY();
            int velocityX = GenerateBallX();

            _ball.Position = new Point(ScreenWidth / 2, ScreenHeight / 2);
            _ball.Velocity = new Point(velocityX, velocityY);

            _currentBallX = velocityX;
        }
        private int GenerateBallX()
        {
            _level += 1;
            int velocityX = _level;
            if (_random.Next(2) == 0)
            {
                velocityX *= -1;
            }
            return velocityX;
        }

        private int GenerateBallY()
        {
            _level += (int).5;
            int velocityY = _random.Next(0, _level);
            if (_random.Next(2) == 0)
            {
                velocityY *= -1;
            }
            return velocityY;
        }

Para que no marque errores con las variables, agregaremos las que faltan como parte de la clase.

        private int _level = 7;
        private int _currentBallX;
        private Random _random;

Finalmente, hay que actualizar la posición de la pelota, y dibujarla a través de los métodos correspondientes.

        private void UpdateScene()
        {
            UpdatePlayer();
            _ball.Update();
        }
        private void DrawScene()
        {
            _player1.Draw();
            _player2.Draw();
            _ball.Draw();
        }

Validando el choque de la pelota contra los paddles y las paredes

Validando el choque contra los paddles

Para realizar estas validaciones, hay que agregar 4 puntos a los sprites, con el objetivo de verificar si la pelota ha chocado contra algún paddle. Para esto, modificaremos la clase GameItem, de la siguiente forma.

        public Point LeftUpCorner
        {
            get { return new Point(Position.X - Origin.X, Position.Y - Origin.Y); }
        }

        public Point RightUpCorner
        {
            get { return new Point(Position.X + Origin.X, Position.Y - Origin.Y); }
        }
        public Point LeftBottomCorner
        {
            get { return new Point(Position.X - Origin.X, Position.Y + Origin.Y); }
        }

        public Point RightBottomCorner
        {
            get { return new Point(Position.X + Origin.X, Position.Y + Origin.Y); }
        }

Y agregaremos el método CheckPaddleCollisions a la clase del formulario.

        private void CheckPaddleCollision()
        {
            if (_ball.LeftUpCorner.X < _player1.RightUpCorner.X &&
                _ball.LeftBottomCorner.Y > _player1.RightUpCorner.Y &&
                _ball.LeftUpCorner.Y < _player1.RightBottomCorner.Y)
            {
                _currentBallX = GenerateBallX();
                if (_currentBallX < 0)
                {
                    _currentBallX *= -1;
                }
                _ball.Velocity = new Point(_currentBallX, GenerateBallY());
            }

            if (_ball.RightUpCorner.X > _player2.LeftUpCorner.X &&
                _ball.RightBottomCorner.Y > _player2.LeftUpCorner.Y &&
                _ball.RightUpCorner.Y < _player2.LeftBottomCorner.Y)
            {
                _currentBallX = GenerateBallX();
                if (_currentBallX > 0)
                {
                    _currentBallX *= -1;
                }
                _ball.Velocity = new Point(_currentBallX, GenerateBallY());
            }
        }

Finalmente, mandamos a llamar el nuevo método a través de UpdateScene:

        private void UpdateScene()
        {
            UpdatePlayer();
            _ball.Update();
            CheckPaddleCollision();
        }

Validando el choque contra las paredes superior e inferior

Para esta validación, crearemos un nuevo método llamado “CheckWallCollision”, el cual debe quedar de la siguiente forma:

        private void CheckWallCollision()
        {
            if (pbBall.Bottom >= ScreenHeight)
            {
                _ball.Velocity = new Point(_currentBallX, -BaseBallSpeed);
            }
            else if (pbBall.Top <= 0)
            {
                _ball.Velocity = new Point(_currentBallX, BaseBallSpeed);
            }
        }

De nuevo, hay que agregar la variable “BaseBallSpeed” como parte de las variables de la clase.

        private int _currentBallX;
        private Random _random;
        private const int BaseBallSpeed = 2;

Y de nuevo, llamamos la comprobación desde el método UpdateScene.

        private void UpdateScene()
        {
            UpdatePlayer();
            _ball.Update();
            CheckPaddleCollision();
            CheckWallCollision();
        }

Validando si la pelota ha salido de la escena

Para esto, hay que validar dónde se encuentra la posición de la pelota a través del siguiente código.

        private void CheckWallOut()
        {
            if (pbBall.Left < 0)
            {
                ResetBall();
                _scorePlayer2 += 1;
                lblScore2.Text = _scorePlayer2.ToString();
            }
            else if (pbBall.Right > ScreenWidth)
            {
                ResetBall();
                _scorePlayer1 += 1;
                lblScore1.Text = _scorePlayer1.ToString();
            }
        }

Hay que agregar las variables que almacenarán el puntaje de los jugadores.

        private int _scorePlayer1;
        private int _scorePlayer2;

Finalmente, llamamos el método desde UpdateScene.

        private void UpdateScene()
        {
            UpdatePlayer();
            _ball.Update();
            CheckPaddleCollision();
            CheckWallCollision();
            CheckWallOut();
        }

Código en Github

Con esto llegamos al final de la entrada, el código es todavía bastante mejorable, simplemente he querido actualizar esta entrada, porque ví que había mucha gente interesada en el tema. Dejo el código en Github con una pieza de código que no he mostrado aquí.

¡Saludos!

Deja un comentario

Tu dirección de correo electrónico no será publicada.