Tutorial Pyxel 6: Retoques finales (1ª parte)

Pyxel

Hola, y bienvenido/a/e a esta sexta entrega de mi tutorial de programación de juegos con Python y Pyxel.

En esta entrega, y la siguiente, vamos a ir dando retoques finales al juego, los cuales consisten en:

  • Añadir nuevas constantes globales. Con esto reducimos el comparar o calcular números sin saber el porqué de ese valor:
    • 3 nuevos estados para el juego (pausa, reseteo, fin del juego)
    • 4 para las palas: 2 para su tamaño y 2 para las posiciones en el eje X de cada pala
    • 2 para la pelota: su radio y velocidad inicial
  • La pelota tendrá ahora 4 velocidades en el eje vertical, cambiando su ángulo. Además, su velocidad sé irá incrementando tras cada golpe con las palas.
  • Una pantalla inicial
  • Una pantalla de partida acabada. La partida termina en cuando la diferencia de puntos es 5
  • Poder pausar el juego

También mostraré como generar un paquete de Pyxel con el contenido de nuestro juego y generar el ejecutable y así poder distribuirlo. De este último tema hablaré más largo y tendido en próximas entregas.

La razón de dividir en dos entregas es para facilitar la lectura, y así no tener una entrega demasiado larga.

Entregas anteriores

Añadiendo las variables constantes

Lo primero que vamos a hacer es añadir las nuevas variables constantes. Añadimos las siguientes líneas tras STATE_PLAYING:

STATE_PAUSE = 3 # Juego en pausa
STATE_RESET = 4 # Cuando se ha anotado un punto
STATE_GAME_OVER = 5 # Se acabo la partida

# Variables globales de las palas y la pelota
PADDLE_WIDTH = 8
PADDLE_HEIGHT = 48 # Altura de la pala
PADDLE1_POS = 8 # Posición en el eje X de la pala del jugador 1
PADDLE2_POS = SCREEN_W - 16 # Posición en el eje X de la pala del jugador 2 (el juego)
BALL_RADIUS = 4 # Radio de la bola
BALL_INIT_SPEED = 4 # Velocidad inicial de la bola

Recordatorio: las variables constantes en Python se definen escribiendo sus nombres en mayúsculas, y dichos valores no pueden ser cambiados durante la ejecución del programa

Modificando la clase Paddle

Hasta ahora el tamaño de las palas se indicaba al crear cada instancia de la clase Paddle, por lo que vamos a proceder a su eliminación y sustitución por las constantes:

  • Eliminamos del método __init__ los parámetros width y height, dejando solo self y pos_x
  • Dentro de ese método, eliminamos estas líneas:
    • self.width = width
    • self.height = height
  • Reemplazamos en toda la clase self.width por PADDLE_WIDTH y self.height por PADDLE_HEIGHT
  • Dentro del método checkCollide veremos varias veces el número 4. Cambiamos dicho valor por la constante BALL_RADIUS, ya que ese es el valor que se debe de sumar y restar para comprobar la colisión

Este es un ejemplo de como usar variables constantes nos sirve para saber con qué valores estamos realizando una operación y dicho valor se va a usar a lo largo del programa. No es obligatorio, pero a la larga puede ser útil, sobre todo si dicho valor (en este ejemplo el 4) se usa en otras partes y evitar así confusiones.

El resultado debería de quedar tal que así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Paddle():
    def __init__(self, pos_x):
        self.pos_x = pos_x
        self.pos_y = (SCREEN_H - PADDLE_HEIGHT) // 2
        self.center = self.pos_y + (PADDLE_HEIGHT // 2)
        self.speed = 6
        self.color = 7

    def draw(self):
        pyxel.rect(self.pos_x, self.pos_y, PADDLE_WIDTH, PADDLE_HEIGHT, self.color)

    def move(self, direction):
        if direction == 'up' and self.pos_y > 0:
            self.pos_y += self.speed * -1
        elif direction == 'down' and self.pos_y + PADDLE_HEIGHT < SCREEN_H:
            self.pos_y += self.speed

        self.center = self.pos_y + (PADDLE_HEIGHT // 2)

    def checkCollide(self, ball):
        if (
            (ball.pos_x + BALL_RADIUS >= self.pos_x and ball.pos_x - BALL_RADIUS <= self.pos_x + PADDLE_WIDTH) and
            (ball.pos_y + BALL_RADIUS >= self.pos_y and ball.pos_y - BALL_RADIUS <= self.pos_y + PADDLE_HEIGHT)
        ):
            return True
        
        return False

    def reset(self):
        self.pos_y = (SCREEN_H - PADDLE_HEIGHT) // 2
        self.center = self.pos_y + (PADDLE_HEIGHT // 2)

Modificando la clase Ball

Vamos a hacer varios cambios en la clase Ball, por lo que para hacerlo más sencillo de entender voy a separarlo en varias partes

Primeros cambios

Lo primero que vamos a cambiar es el valor inicial de Ball.speed por el valor de la constante BALL_INIT_SPEED. Para ello buscamos esta línea

self.speed = 4

Y cambiamos su valor por:

self.speed = BALL_INIT_SPEED

Ahora eliminamos la línea self.radius = 4 dentro de __init__ y reemplazamos en el método draw dicha variable por BALL_RADIUS como parámetro de pyxel.circ.

Nos debería de quedar de este modo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Ball():
    def __init__(self):
        self.pos_x = (SCREEN_W - 2) // 2
        self.pos_y = (SCREEN_H - 2) // 2
        self.speed_x = 0
        self.speed_y = 0
        self.speed = BALL_INIT_SPEED
        self.color = 7

    def changeDir(self, direction):
        self.speed_x = self.speed if direction == 'right' else self.speed * -1

    def initMove(self):
        x_move = choice(['left', 'right'])
        self.speed_x = self.speed if x_move == 'right' else self.speed * -1

        y_move = choice(['up', 'down'])
        self.speed_y = self.speed if y_move == 'down' else self.speed * -1

    def draw(self):
        pyxel.circ(self.pos_x, self.pos_y, BALL_RADIUS, self.color)

    def reset(self):
        self.pos_x = (SCREEN_W - 4) // 2
        self.pos_y = (SCREEN_H - 4) // 2
        self.speed_x = 0
        self.speed_y = 0

Nuevos ángulos

En el Pong original la bola, al colisionar contra una de las palas, cambia su ángulo dependiendo del punto de colisión con esa pala, pero para este ejemplo vamos a usar un metodo más sencillo.

Para ello vamos a realizar cambios en el método changeDir

Lo primero que vamos a hacer es añadir un nuevo parámetro al metodo, paddle, y cuyo valor es la pala con la que colisiono la bola:

def changeDir(self, direction, paddle):

Ahora lo que vamos a hacer es comprobar el punto de colisión de la bola en referencia al centro de la pala y almacenarlo en una variable que usaremos luego para saber si la pelota irá hacia arriba o hacia abajo.

Para ello, añadimos la siguiente línea tras definir el método:

1
2
def changeDir(self, direction, paddle):
        diff = self.pos_y - paddle.center

Lo siguiente es seleccionar de manera aleatoria dos valores, 0 y 1, usando la función random.choice, y según el valor devuelto, definiremos la velocidad en el eje vertical, cambiando así el ángulo de la pelota:

1
2
random_vel_y = choice([0, 1])
dy = 1 if random_vel_y == 0 else 0.

Por último, vamos a definir la velocidad de la pelota en el eje vertical, usando para ello los valores de diff y dy

1
2
3
4
if diff < 0:
    self.speed_y = self.speed * (-1 * dy)
elif diff > 0:
    self.speed_y = self.speed * dy

La velocidad vertical de la pelota será, la velocidad actual de la pelota, multiplicada por el valor de dy, siendo este último valor convertido a negativo si el valor de diff es negativo (golpeo en la mitad superior de la pala)

Modificando la clase Pyng

Ahora vamos a realizar modificaciones en la clase principal del juego, Pyng

Por el momento, solo vamos a realizar cuatro modificaciones en el método update.

Lo primero que vamos a modificar son estas líneas:

1
2
3
4
5
if self.paddle1.checkCollide(self.ball):
    self.ball.changeDir('right')

if self.paddle2.checkCollide(self.ball):
    self.ball.changeDir('left')

Como vimos en el último punto de los cambios en Ball, ahora su método changeDir() pide un segundo parámetro, paddle, que es la instancia de la pala, por lo que añadimos ese parámetro según corresponda:

1
2
3
4
5
if self.paddle1.checkCollide(self.ball):
    self.ball.changeDir('right', self.paddle1)

if self.paddle2.checkCollide(self.ball):
    self.ball.changeDir('left', self.paddle2)

Si vamos un poco más abajo veremos lo siguiente:

1
2
3
4
5
6
if self.ball.pos_x + self.ball.radius <= 0:
    self.p2_score += 1
    self.state = STATE_INIT
elif self.ball.pos_x + self.ball.radius >= SCREEN_W:
    self.p1_score += 1
    self.state = STATE_INIT

En este caso, sustituimos los 2 self.ball.radius por BALL_RADIUS, ya que elimínanos dicha propiedad, justo para usar esta constante

1
2
3
4
5
6
if self.ball.pos_x + BALL_RADIUS <= 0:
    self.p2_score += 1
    self.state = STATE_INIT
elif self.ball.pos_x + BALL_RADIUS >= SCREEN_W:
    self.p1_score += 1
    self.state = STATE_INIT

Si ejecutamos el juego, tendremos algo como lo siguiente:

Ejemplo de los nuevos angulos de la bola

Como se puede apreciar al final del GIF, la pala que maneja el juego va dando saltos, pero esto es algo que solucionaremos en la segunda parte.

Código completo de esta entrega
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import pyxel
from random import choice


# Variables globales del juego
SCREEN_W = 320
SCREEN_H = 240
STATE_INIT = 1 # Inicio del juego
STATE_PLAYING = 2 # Jugando
STATE_PAUSE = 3 # Juego en pausa
STATE_RESET = 4 # Cuando se ha anotado un punto
STATE_GAME_OVER = 5 # Se acabó la partida

# Variables globales de las palas y la pelota
PADDLE_WIDTH = 8
PADDLE_HEIGHT = 48 # Altura de la pala
PADDLE1_POS = 8 # Posición en el eje X de la pala del jugador 1
PADDLE2_POS = SCREEN_W - 16 # Posición en el eje X de la pala del jugador 2 (el juego)
BALL_RADIUS = 4 # Radio de la bola
BALL_INIT_SPEED = 4 # Velocidad inicial de la bola


class Paddle():
    def __init__(self, pos_x):
        self.pos_x = pos_x
        self.pos_y = (SCREEN_H - PADDLE_HEIGHT) // 2
        self.center = self.pos_y + (PADDLE_HEIGHT // 2)
        self.speed = 6
        self.color = 7

    def draw(self):
        pyxel.rect(self.pos_x, self.pos_y, PADDLE_WIDTH, PADDLE_HEIGHT, self.color)

    def move(self, direction):
        if direction == 'up' and self.pos_y > 0:
            self.pos_y += self.speed * -1
        elif direction == 'down' and self.pos_y + PADDLE_HEIGHT < SCREEN_H:
            self.pos_y += self.speed

        self.center = self.pos_y + (PADDLE_HEIGHT // 2)

    def checkCollide(self, ball):
        if (
            (ball.pos_x + BALL_RADIUS >= self.pos_x and ball.pos_x - BALL_RADIUS <= self.pos_x + PADDLE_WIDTH) and
            (ball.pos_y + BALL_RADIUS >= self.pos_y and ball.pos_y - BALL_RADIUS <= self.pos_y + PADDLE_HEIGHT)
        ):
            return True
        
        return False

    def reset(self):
        self.pos_y = (SCREEN_H - PADDLE_HEIGHT) // 2
        self.center = self.pos_y + (PADDLE_HEIGHT // 2)

    
class Ball():
    def __init__(self):
        self.pos_x = (SCREEN_W - 2) // 2
        self.pos_y = (SCREEN_H - 2) // 2
        self.speed_x = 0
        self.speed_y = 0
        self.speed = BALL_INIT_SPEED
        self.color = 7

    def changeDir(self, direction, paddle):
        # La velocidad vertical va a cambiar según la distancia de la pelota
        # con el centro de la pala
        diff = self.pos_y - paddle.center

        # Ahora vamos a seleccionar de manera aleatoria un valor que indicara
        # la velocidad en el eje vertical
        random_vel_y = choice([0, 1])
        dy = 1 if random_vel_y == 0 else 0.4

        # Sí la diferencia con el centro es negativo, la bola ira hacia arriba
        if diff < 0:
            self.speed_y = self.speed * (-1 * dy)
        # En caso contrario, hacia abajo
        elif diff > 0:
            self.speed_y = self.speed * dy

        self.speed_x = self.speed if direction == 'right' else self.speed * -1

    def initMove(self):
        x_move = choice(['left', 'right'])
        self.speed_x = self.speed if x_move == 'right' else self.speed * -1

        y_move = choice(['up', 'down'])
        self.speed_y = self.speed if y_move == 'down' else self.speed * -1

    def draw(self):
        pyxel.circ(self.pos_x, self.pos_y, BALL_RADIUS, self.color)

    def reset(self):
        self.pos_x = (SCREEN_W - 4) // 2
        self.pos_y = (SCREEN_H - 4) // 2
        self.speed_x = 0
        self.speed_y = 0
        self.speed = BALL_INIT_SPEED

    def update(self):
        # Comprobamos si la bola choca contra la parte superior o inferior de la pantalla
        if self.pos_y - 6 <= 0 or self.pos_y + 6 >= SCREEN_H:
            self.speed_y *= -1

        # Y finalmente movemos la bola
        self.pos_x += self.speed_x
        self.pos_y += self.speed_y

class Pyng():
    def __init__(self):
        self.paddle1 = Paddle(PADDLE1_POS)
        self.paddle2 = Paddle(PADDLE2_POS)
        self.ball = Ball()
        self.ball.initMove()
        self.p1_score = 0
        self.p2_score = 0
        self.state = STATE_INIT

        pyxel.init(SCREEN_W, SCREEN_H, 'Pyng')
        pyxel.run(self.update, self.draw)

    def draw(self):
        pyxel.cls(0)
        self.paddle1.draw()
        self.paddle2.draw()
        self.ball.draw()
        pyxel.text((SCREEN_W // 2 - 8), 8, str(self.p1_score), 7)
        pyxel.text((SCREEN_W // 2 + 8), 8, str(self.p2_score), 7)

    def update(self):
        if self.state == STATE_PLAYING:
            if pyxel.btn(pyxel.KEY_Q):
                self.paddle1.move('up')
            elif pyxel.btn(pyxel.KEY_A):
                self.paddle1.move('down')
            else:
                self.paddle1.move(None)

            if self.paddle1.checkCollide(self.ball):
                self.ball.changeDir('right', self.paddle1)

            if self.paddle2.checkCollide(self.ball):
                self.ball.changeDir('left', self.paddle2)

            if self.ball.pos_y < self.paddle2.center:
                self.paddle2.move('up')
            elif self.ball.pos_y > self.paddle2.center:
                self.paddle2.move('down')

            if self.ball.pos_x + BALL_RADIUS <= 0:
                self.p2_score += 1
                self.state = STATE_INIT
            elif self.ball.pos_x + BALL_RADIUS >= SCREEN_W:
                self.p1_score += 1
                self.state = STATE_INIT
        elif self.state == STATE_INIT:
            self.ball.reset()
            self.paddle1.reset()
            self.paddle2.reset()

            if pyxel.btn(pyxel.KEY_SPACE):
                self.ball.initMove()
                self.state = STATE_PLAYING

        self.ball.update()


Pyng()

Y esto es todo por el momento. En la siguiente entrega seguiremos con los retoques finales.

Recuerda que el código fuente de cada entrega del tutorial lo tienes disponible en este repositorio. Hasta pronto

Share: