Pong en Python. Ramas y bugs

por Fran Iglesias

Las cosas no siempre salen como se planean, ¿cómo reaccionar en un entorno ágil?

Bugs

Después de entregar nuestra última feature hemos descubierto que se ha generado un bug. Por algún motivo el evento QUIT que usamos para abortar el juego haciendo clic en el botón de cerrar la ventana o pulsando la combinación de teclas equivalente y salir antes de que termine acaba provocando la salida prematura del programa. Es decir, se detiene en la pantalla de despedida durante el tiempo de delay, que es de un segundo, y sale inmediatamente sin permitir que la jugadora pueda decidir si quiere salir o volver a jugar.

Por otro lado, además, ha habido algunas quejas sobre esta última pantalla. Al mostrarse todavía la imagen del campo, resulta bastante confusa y poco clara. Esto se ha descubierto precisamente porque ahora hay una pantalla en la que detenerse y decidir.

Así que tenemos dos bugs:

  • Al salir del juego cerrando la ventana, se acaba saliendo de la aplicación.
  • La pantalla de salida es confusa y deberíamos quitarle contenido.

La cuestión es: ¿estos bugs deben entrar en el sprint o no?

Cuando aparecen bugs es necesario hacer una evaluación de su gravedad y su urgencia. Algunos deberán entrar inmediatamente al sprint, incluso con la máxima prioridad. Otros quedarán como historias del backlog, aunque se pongan en lugares altos.

En nuestro ejemplo, el sprint había quedado así tras la última entrega:

  • US-3 La pala de la jugadora humana se mueve más rápido
  • US-4 Al jugar contra el ordenador se puede escoger el lado de la mesa

Así que ahora tenemos dos bugs:

  • BUG-1 Al salir del juego cerrando la ventana, se acaba saliendo de la aplicación.
  • BUG-2 La pantalla de salida es confusa y deberíamos quitarle contenido.

La valoración es. El BUG-1 nos resulta más urgente y lo introducimos en el sprint, pero no pensamos que sea necesario alterar las prioridades. Por su parte, añadimos el BUG-2 al Backlog priorizado, para tratarlo cuanto antes tras terminar el sprint actual. Como resultado, este es nuestro sprint:

  • US-3 La pala de la jugadora humana se mueve más rápido
  • US-4 Al jugar contra el ordenador se puede escoger el lado de la mesa
  • BUG-1 Al salir del juego cerrando la ventana, se acaba saliendo de la aplicación.

Y así queda el backlog ahora:

  • BUG-2 La pantalla de salida es confusa y deberíamos quitarle contenido.
  • US-5 Opcionalmente, Pueden jugar dos personas, controlando cada raqueta
  • US-6 La partida se puede configurar para 3 ó 5 sets a 21 puntos con dos puntos de diferencia para el ganador
  • US-7 El nivel de dificultad del juego puede ser seleccionado
  • US-8 Mostrar la línea divisoria del campo de juego
  • US-9 El efecto de rebote de la pelota es más realista
  • US-10 Efecto de sonido diferenciado cuando se hace un tanto
  • US-11 Se puede jugar en modalidad dobles (se necesita más información)

(He aprovechado para numerar la historias y que sea más fácil identificarlas, esto es algo que las herramientas tipo JIRA y similares automatizan por nosotros.)

Flujo de trabajo y ramas

El otro tema que trataremos en este artículo es el flujo de trabajo y despliegue. Hasta ahora hemos estado haciendo commits atómicos y push a la rama principal. Esto no es una mala práctica dado que nos permite reducir al máximo el tiempo para llegar a producción. Sin embargo, hay muchas situaciones y proyectos en los que esto no se puede hacer sin más. Para ello se definen flujos de trabajo. No hay un flujo de trabajo ideal y cada equipo debe buscar el suyo.

Algunos principios que se pueden aplicar:

  • Una única rama base, que es la que se despliega a producción.
  • Resolver los conflictos de mezcla en local.
  • Integrar los cambios en un solo commit atómico, aunque hayamos usado varios para el desarrollo.

En general, el Trunk based development encaja mucho mejor con la metodología ágil, evita problemas a la hora de las mezclas y permite tener un estado limpio de la línea troncal del software. Para ello tienen que integrarse con un sistema de chequeo pre-integración que verifique que el software cumple con los requisitos establecidos, como pasar los tests, chequeos de estilo, calidad, etc.

Los commits directos a la línea principal o troncal funcionan bien para desarrolladoras únicas o equipos muy pequeños, pero en equipos más grandes, o cuando varios equipos trabajan en el mismo proyecto, es preferible usar una estrategia de ramas de vida corta.

Una rama de vida corta no es más que una rama que se extrae del tronco para desarrollar una feature concreta o una parte de ella. Podría ser una historia de usuario, si no es muy grande, o una subtarea dentro de la historia. La vida corta se refiere a que esa rama debería integrarse al tronco en un par de días como mucho. En git disponemos de la herramienta rebase, que nos permite traernos el estado actual de la rama principal, resolver los posible conflictos y añadir nuestros commits al final. Esto nos facilitará alargar la vida de la rama si es necesario al permitirnos resolver los conflictos.

Con git el proceso es más o menos el siguiente. Nos situamos inicialmente en master local:

# Actualizamos la rama principal

git pull --all

# Creamos una rama nueva en ese punto de la historia

git checkout -b US-3_human_pad_moves_faster

# Trabajamos en la rama y vamos haciendo commits

git add .
git commit -m "Added things..."

# Si hay cambios en la rama principal, los podemos traer, resolviendo los conflictos que pueda haber

git pull --rebase origin master

# Trabajamos en la rama y vamos haciendo nuevos commits

git add .
git commit -m "Added things..."

# Para finalizar volvemos a hacer rebase

git pull --rebase origin master

# Opcionalmente, reorganizamos los commits de la rama (N es el número de commits que vamos a manipular)

git rebase --interactive HEAD~N

# Una forma alternativa es indicar a partir de qué commit lo haremos (commit-sha es el primer commit del grupo que queremos reorganizar)

git rebase --interactive commit-sha

# Publicamos los cambios de la rama

git push -u origin US-3_human_pad_moves_faster

Si trabajas con GitHub tienes la opción de crear aquí el pull request. Por otro lado, en este enlace tienes más información sobre la reagrupación de commits.

Volviendo al sprint

Tras estos cambios, el sprint ha quedado así:

  • US-3 La pala de la jugadora humana se mueve más rápido
  • US-4 Al jugar contra el ordenador se puede escoger el lado de la mesa
  • BUG-1 Al salir del juego cerrando la ventana, se acaba saliendo de la aplicación.

Como hemos dicho, vamos a la primera historia.

Acelerando la pala

No debería tener mucha complicación. Básicamente se trata de modificar la cantidad de pixels que se mueve la pala. Actualmente está definido así:

    def up(self):
        self.dy = -1

    def down(self):
        self.dy = 1

Pero si observamos, vemos que hay una dependencia:

    def follow(self, the_ball: pong.ball.Ball):
        if the_ball.rect.y > self.rect.y:
            self.down()
        if the_ball.rect.y < self.rect.y:
            self.up()

Si modificamos los métodos up y down, modificaremos también la velocidad del jugador ordenador. Por tanto tenemos que darle una vueltecita.

Una posibilidad es definir una propiedad speed que se inicializaría de forma diferente para el caso de una jugadora humana y para el caso de que sea controlada por el ordenador, pasándola en el momento de la construcción.

Por otro lado. Tener que tocar aquí nos hace ver un problemita. La clase pad representa a la vez dos maneras de ser usada: una manual y otra automática. Podría ser buen momento para representar esa diferencia en forma de clases que extienden de una base. Y de paso, limpiar algunas cosas que finalmente no están siendo usadas y que nos quedaron pendientes.

Vamos a ello. Empezamos por parametrizar la velocidad para poder en cuenta en la instanciación de cada tipo de pala. Con un valor por defecto en la signatura del constructor conseguimos introducirla sin romper los tests.

import pygame

import pong.ball
import pong.config


class Pad(pygame.sprite.Sprite):
    def __init__(self, side, speed=1):
        super().__init__()

        self.speed = speed
        
        self.max_ability = 10
        self.computer_ability = 10
        self.min_ability = 0

        self.top_region_pct = 10
        self.middle_region_pct = 15

        self.width = 25
        self.height = 75

        self.dy = 0

        self.image = pygame.Surface((self.width, self.height))
        self.image.fill(pong.config.white)

        self.rect = self.image.get_rect()

        if side == 'left':
            self.margin = 25
        else:
            self.margin = 775 - self.width

        self.rect.y = 300
        self.rect.x = self.margin
        self.borders = None

    def up(self):
        self.dy = -1

    def down(self):
        self.dy = 1

    def stop(self):
        self.dy = 0

    def update(self):
        self.rect.y += self.dy

        border_collisions = pygame.sprite.spritecollide(self, self.borders, False)
        for _ in border_collisions:
            self.rect.y -= self.dy
            self.stop()

    def follow(self, the_ball: pong.ball.Ball):
        if the_ball.rect.y > self.rect.y:
            self.down()
        if the_ball.rect.y < self.rect.y:
            self.up()

    def hit(self, ball):
        ball_center_y = ball.rect.y + ball.radius - self.rect.y

        if ball_center_y < self.__top_region_limit():
            ball.bounce_with_pad_top()
        elif ball_center_y > self.__bottom_region_limit():
            ball.bounce_with_pad_bottom()
        elif self.__top_region_limit() <= ball_center_y < self.__upper_middle_limit():
            ball.bounce_middle_pad()
        elif self.__bottom_middle_limit() < ball_center_y <= self.__bottom_region_limit():
            ball.bounce_middle_pad()
        else:
            ball.bounce_with_pad()

    def __top_region_limit(self):
        return self.top_region_pct * self.height // 100

    def __upper_middle_limit(self):
        return (self.middle_region_pct + self.top_region_pct) * self.height // 100

    def __bottom_middle_limit(self):
        return ((100 - self.top_region_pct - self.middle_region_pct) * self.height) // 100

    def __bottom_region_limit(self):
        return ((100 - self.top_region_pct) * self.height) // 100

Ahora, utilizamos la nueva propiedad:

    def up(self):
        self.dy = -self.speed

    def down(self):
        self.dy = self.speed

Los tests siguen pasando, así que vamos a configurar la pala “humana” con velocidad 2 y probaremos el juego.

Efectivamente, con este cambio hemos logrado una mejor sensación de jugabilidad. El movimiento de la pala es más ágil y más satisfactorio y controlarla es un poquito más difícil, lo que añade interés al juego.

Por lo que respecta a la tarea, el objetivo está cumplido y podríamos entregar. Ahora vamos a hacer un poco de limpieza:

Estas tres propiedades no se usan en ningún sitio y, de hecho, no pertenecen a la pala, así que las eliminamos.

        self.max_ability = 10
        self.computer_ability = 10
        self.min_ability = 0

Respecto a la parte de especializar la clase pad según corresponda a una jugadora humana o jugador ordenador nos surgen dudas, parece algo más complejo de lo esperado. El bucle del juego es este:

        while not done:
            # Event
            for event in pygame.event.get():
                if event.type == pong.config.COMPUTER_MOVES_EVENT:
                    pad_right.follow(ball)
                if event.type == pygame.QUIT:
                    done = True

            # Game logic
            pygame.event.pump()
            key = pygame.key.get_pressed()
            if key[pygame.K_w]:
                pad_left.up()
            elif key[pygame.K_s]:
                pad_left.down()
            else:
                pad_left.stop()

            all_sprites.update()

Aquí se puede ver que posiblemente tendríamos que cambiar de alguna manera el modo en que manejamos los eventos. Posiblemente pasando los eventos a los pads para que cada uno de ellos responda a los que le corresponden. Pero eso puede ser un trabajo complejo y nos pararía el desarrollo del sprint.

Mi planteamiento en este caso es: sigamos adelante con el sprint y si tenemos tiempo al final, podemos investigar esta parte. Recuerda: este cambio no va a entregar más valor. Es una mejora de calidad del software que nos interesaría conseguir y que podría llegar a facilitarnos la entrega de valor en el futuro, pero la inversión ahora mismo es muy alta.

Así que hacemos un commit para que nuestras jugadoras se lo pasen mejor con el juego.

US-4 mayor diversidad

La historia US-4 “Al jugar contra el ordenador se puede escoger el lado de la mesa”, nos permitirá ofrecer más opciones a las jugadoras y tiene algunas implicaciones interesantes. Por ejemplo, facilitará que las personas escojan el lado de la pantalla más cómodo según sean zurdas o diestras. Esto nos lleva a una cuestión más importante que no fue recogida en la historia: el control por teclado. Es decir, también podría ser interesante ofrecer la posibilidad de configurar las teclas con las que se maneja el juego.

De nuevo, esto nos lleva a un desarrollo más complejo, pero que puede aportar mucho valor. ¿qué hacemos? Pues añadir la historia al backlog, de modo que podamos tratarla con más detenimiento y analizar cómo lo haremos. Así que:

  • US-12 Permitir configurar las teclas de control del juego

Afortunadamente, el control por teclado no es crítico en este momento ya que son solo dos teclas y basta mover el teclado para encontrar una posición cómoda. Ahora mismo, de hecho, las teclas son, por pura arbitrariedad W y S. Podríamos haber configurado las teclas de flechas arriba y abajo, etc.

Pero centrémonos en la tarea del sprint. En principio, cambiar el lado de la mesa puede ser relativamente fácil, así que examinamos primero cómo podría hacerse efectivo y luego veremos cómo permitir la elección.

El cambio debería hacerse aquí:

        ball = pong.ball.Ball(pong.config.yellow, 10)
        pad_left = pong.game.pad.Pad('left', 2)
        pad_right = pong.game.pad.Pad('right')
        pads = pygame.sprite.Group()
        pads.add(pad_left)
        pads.add(pad_right)
        border_top = pong.border.Border(0)
        border_bottom = pong.border.Border(590)
        player1 = pong.player.Player('left')
        player2 = pong.player.Player('computer')
        self.window.score_board = pong.scoreboard.ScoreBoard(player1, player2)
        goal_left = pong.goal.Goal(0, player2)
        goal_right = pong.goal.Goal(790, player1)

Si lo cambiamos de esta manera:

        clock = pygame.time.Clock()
        ball = pong.ball.Ball(pong.config.yellow, 10)
        pad_left = pong.game.pad.Pad('right', 2)
        pad_right = pong.game.pad.Pad('left')
        pads = pygame.sprite.Group()
        pads.add(pad_left)
        pads.add(pad_right)
        border_top = pong.border.Border(0)
        border_bottom = pong.border.Border(590)
        player1 = pong.player.Player('left')
        player2 = pong.player.Player('computer')
        self.window.score_board = pong.scoreboard.ScoreBoard(player1, player2)
        goal_left = pong.goal.Goal(0, player1)
        goal_right = pong.goal.Goal(790, player2)

Podremos jugar con la jugadora humana a nuestra derecha.

Este área del código es muy confusa y, aunque el cambio es simple, hay varias cosas que mejorar para que todo sea más comprensible. Para empezar, estamos identificando los pads y las metas por su posición. Aparte hay varios valores mágicos que podrían estar en config.py. Así que primero vamos a limpiar un poco:

Primera fase:

        ball = pong.ball.Ball(pong.config.yellow, 10)
        human_pad = pong.game.pad.Pad('right', 2)
        computer_pad = pong.game.pad.Pad('left')
        pads = pygame.sprite.Group()
        pads.add(human_pad)
        pads.add(computer_pad)
        border_top = pong.border.Border(0)
        border_bottom = pong.border.Border(590)
        human_player = pong.player.Player('human')
        computer_player = pong.player.Player('computer')
        self.window.score_board = pong.scoreboard.ScoreBoard(human_player, computer_player)
        goal_left = pong.goal.Goal(0, human_player)
        goal_right = pong.goal.Goal(790, computer_player)

Segunda fase:

        human_side = pong.config.human_side
        if human_side == 'left':
            computer_side = 'right'
        else:
            computer_side = 'left'

        human_player = pong.player.Player('human')
        human_pad = pong.game.pad.Pad(human_side, 2)

        computer_pad = pong.game.pad.Pad(computer_side)
        computer_player = pong.player.Player('computer')

        goal_left = pong.goal.Goal(0, human_player)
        goal_right = pong.goal.Goal(790, computer_player)
        border_top = pong.border.Border(0)
        border_bottom = pong.border.Border(590)

        self.window.score_board = pong.scoreboard.ScoreBoard(human_player, computer_player)

Hemos llevado la creación del grupo de pads a otra parte un poco más abajo:

        pads = pygame.sprite.Group()
        pads.add(human_pad)
        pads.add(computer_pad)

Verificamos que todo funciona, empezando por los tests, y ya estamos en mejor situación que antes. Nos basta cambiar el valor de la variable pong.config.human_side para cambiar la posición de la jugadora.

Sin embargo, nosotras la usamos en una escena mientras que tenemos que cambiarlo en otra. Necesitamos poder mover esa información de una escena a otra. Actualmente contamos con el objeto Window accesible desde todas las escenas, pero no parece buena idea usarlo directamente para añadir datos de la partida. En su lugar, introduciremos un objeto Game que irá vinculado a Window y que se encargará de contener esa y otras informaciones en el futuro.

Game contendrá entonces la información que define cómo será una partida concreta, obtiene sus defaults del config.py, pero tiene métodos que permiten modificarlos.

import unittest

from pong.game.game import Game


class GameTestCase(unittest.TestCase):
    def test_should_allow_to_set_side_preference(self):
        game = Game()
        game.set_side_preference('right')
        self.assertEqual('right', game.side_preference)


if __name__ == '__main__':
    unittest.main()

La clase Game puede empezar siendo así:

import pong.config


class Game(object):

    def __init__(self):
        self.side_preference = pong.config.human_side

    def set_side_preference(self, side_preference):
        self.side_preference = side_preference

Ahora nos toca añadirla en Window. En principio la instanciaremos en el mismo constructor.

import pygame

import pong.game.game


class Window(object):
    def __init__(self, width: int, height: int, title: str):
        self.width = width
        self.height = height
        self.title = title
        size = (self.width, self.height)
        self.screen = pygame.display.set_mode(size)
        pygame.display.set_caption(self.title)

        self.score_board = None
        self.PLAY_AGAIN = 1

        self.game = pong.game.game.Game()

        self.scenes = []

    def run(self):
        exit_code = 0
        for scene in self.scenes:
            exit_code = scene.run()
            if self.is_error(exit_code):
                break
        if exit_code == self.PLAY_AGAIN:
            return self.run()
        return exit_code

    def add_scene(self, scene):
        self.scenes.append(scene)

    @staticmethod
    def is_error(exit_code):
        return exit_code < 0

Y ahora cambiamos este bloque en GameScene:

        human_side = self.window.game.side_preference
        if human_side == 'left':
            computer_side = 'right'
        else:
            computer_side = 'left'

De este modo, ya tenemos que GameScene se configura con la información provista por Game, la cual podremos modificar en cualquier otra Scene. Nosotros queremos hacerlo en StartScene.

La interfaz que vamos a usar es sencilla. Utilizaremos pulsaciones de teclas para cambiar estos ajustes: L para escoger la posición Left y R para escoger la posición Right. También necesitamos mostrar cual es el ajuste actual, las instrucciones para hacer el cambio y que dar una confirmación visual.

Son unas cuantas cosas. En este momento es prudente por nuestra parte hacer un commit de los cambios preparatorios ya que mantienen los tests pasando y el juego utilizable.

US-4 interfaz para ajustar las preferencias

StartScene consiste básicamente en un bucle que espera que se pulse alguna tecla. Al igual que ocurre en EndScene tenemos que determinar qué tecla ha sido pulsada para actuar en consecuencia.

Primero hacemos un cambio en el test de StartScene y modificamos la manera en que capturamos el evento de la pulsación de una tecla:

import unittest.mock

import pygame

import pong.scenes.startscene
from pong.app.window import Window
from pong.tests import events


class StartSceneTestCase(unittest.TestCase):
    @unittest.mock.patch('pygame.event.wait', return_value=events.any_key_event)
    def test_should_run_fine(self, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        scene = pong.scenes.startscene.StartScene(window)

        pygame.init()
        self.assertEqual(0, scene.run())
        pygame.quit()


if __name__ == '__main__':
    unittest.main()

StartScene:

        while not done:
            event = pygame.event.wait()
            if event.type in (pygame.KEYDOWN, pygame.KEYUP):
                key_name = pygame.key.name(event.key)
                if key_name == "p":
                    exit_code = self.window.PLAY_AGAIN
                done = True

        return exit_code

Ahora vamos a hacer que se pueda cambiar la preferencia de lado pulsando las teclas L ó R. De momento no vamos a mostrar nada. Lo definimos en un test en el que simulamos la pulsación de la tecla R y luego de una tecla cualquiera para salir de la escena.

import unittest.mock

import pygame

import pong.scenes.startscene
from pong.app.window import Window
from pong.tests import events


class StartSceneTestCase(unittest.TestCase):
    @unittest.mock.patch('pygame.event.wait', return_value=events.any_key_event)
    def test_should_run_fine(self, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        scene = pong.scenes.startscene.StartScene(window)

        pygame.init()
        self.assertEqual(0, scene.run())
        pygame.quit()

    @unittest.mock.patch('pygame.event.wait', side_effect=[events.r_key_event, events.any_key_event])
    def test_should_change_side_preference(self, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        scene = pong.scenes.startscene.StartScene(window)

        pygame.init()
        scene.run()
        self.assertEqual('right', window.game.side_preference)
        pygame.quit()


if __name__ == '__main__':
    unittest.main()

Esto nos lleva al siguiente código:

import pygame

import pong.config
import pong.utils.textrenderer
from pong.app.scene import Scene
from pong.app.window import Window


class StartScene(Scene):
    def __init__(self, window: Window):
        super().__init__(window)

    def run(self):
        image = pygame.image.load(pong.config.basepath + '/assets/pong.jpg')

        self.window.screen.fill(pong.config.white)

        self.window.screen.blit(image, (0, 0))
        self.text_renderer.blit('Press any key to play', pong.config.style_prompt)

        done = False
        while not done:
            event = pygame.event.wait()
            if event.type in (pygame.KEYDOWN, pygame.KEYUP):
                key_name = pygame.key.name(event.key)
                if key_name == "r":
                    self.window.game.set_side_preference('right')
                else:
                    done = True

            pygame.display.flip()

        return 0

Ahora, si probamos el juego deberíamos poder escoger jugar a la derecha de la pantalla, cosa que ocurre. Hacemos lo mismo para dar soporte a la tecla L.

import unittest.mock

import pygame

import pong.scenes.startscene
from pong.app.window import Window
from pong.tests import events


class StartSceneTestCase(unittest.TestCase):
    @unittest.mock.patch('pygame.event.wait', return_value=events.any_key_event)
    def test_should_run_fine(self, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        scene = pong.scenes.startscene.StartScene(window)

        pygame.init()
        self.assertEqual(0, scene.run())
        pygame.quit()

    @unittest.mock.patch('pygame.event.wait', side_effect=[events.r_key_event, events.any_key_event])
    def test_should_change_side_preference(self, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        scene = pong.scenes.startscene.StartScene(window)

        pygame.init()
        scene.run()
        self.assertEqual('right', window.game.side_preference)
        pygame.quit()

    @unittest.mock.patch('pygame.event.wait', side_effect=[events.l_key_event, events.any_key_event])
    def test_should_change_side_preference(self, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        scene = pong.scenes.startscene.StartScene(window)
        window.game.set_side_preference('right')
        pygame.init()
        scene.run()
        self.assertEqual('left', window.game.side_preference)
        pygame.quit()


if __name__ == '__main__':
    unittest.main()

import pygame

import pong.config
import pong.utils.textrenderer
from pong.app.scene import Scene
from pong.app.window import Window


class StartScene(Scene):
    def __init__(self, window: Window):
        super().__init__(window)

    def run(self):
        image = pygame.image.load(pong.config.basepath + '/assets/pong.jpg')

        self.window.screen.fill(pong.config.white)

        self.window.screen.blit(image, (0, 0))
        self.text_renderer.blit('Press any key to play', pong.config.style_prompt)

        done = False
        while not done:
            event = pygame.event.wait()
            if event.type in (pygame.KEYDOWN, pygame.KEYUP):
                key_name = pygame.key.name(event.key)
                if key_name == "r":
                    self.window.game.set_side_preference('right')
                elif key_name == 'l':
                    self.window.game.set_side_preference('left')
                else:
                    done = True
                    
            pygame.display.flip()

        return 0

Nos queda un poco todavía. Tenemos que mostrar en pantalla el estado del ajuste y que se puedan ver los cambios, así como indicar cómo hacerlo de alguna manera.

De momento, vamos a probar esto. Tiene mucho margen de mejora pero es bastante funcional:

import pygame

import pong.config
import pong.utils.textrenderer
from pong.app.scene import Scene
from pong.app.window import Window


class StartScene(Scene):
    def __init__(self, window: Window):
        super().__init__(window)

    def run(self):
        image = pygame.image.load(pong.config.basepath + '/assets/pong.jpg')

        self.window.screen.fill(pong.config.white)

        self.window.screen.blit(image, (0, 0))
        self.text_renderer.blit('Press any key to play', pong.config.style_prompt)

        done = False
        while not done:
            event = pygame.event.wait()
            if event.type in (pygame.KEYDOWN, pygame.KEYUP):
                key_name = pygame.key.name(event.key)
                if key_name == "r":
                    self.window.game.set_side_preference('right')
                elif key_name == 'l':
                    self.window.game.set_side_preference('left')
                else:
                    done = True

            self.window.screen.fill(pong.config.white)
            self.window.screen.blit(image, (0, 0))
            self.text_renderer.blit('Press any key to play', pong.config.style_prompt)
            self.text_renderer.blit("Table side: L/R ({0}) ".format(self.window.game.side_preference), pong.config.style_config)

            pygame.display.flip()

        return 0

Si añadimos más opciones en el futuro necesitaremos hacer cambios, pero por ahora es suficiente.

Casi hemos terminado. Pero al probar el juego descubrimos que el marcador es engañoso ya que no tiene en cuenta esta preferencia. Vamos a ver qué podemos hacer para solucionarlo.

Básicamente, lo que tenemos que hacer es asignar las “porterías” a la jugadora adecuada en función de la preferencia. De paso, hemos decidido hacer algunos arreglos en ScoreBoard para asegurarnos de que la representación es correcta:

Estos cambios en GameScene:

        if human_side == 'left':
            goal_left = pong.goal.Goal(0, computer_player)
            goal_right = pong.goal.Goal(790, h)
            self.window.score_board = pong.scoreboard.ScoreBoard(human_player, computer_player)
        else:
            goal_left = pong.goal.Goal(0, human_player)
            goal_right = pong.goal.Goal(790, computer_player)
            self.window.score_board = pong.scoreboard.ScoreBoard(computer_player, human_player)

Añadimos tests para ScoreBoard, con lo cual nos aseguramos de que el comportamiento básico que necesitamos se cumple. Además mejoramos un poco el código:

from unittest import TestCase

import pong.player
import pong.scoreboard


class TestScoreBoard(TestCase):

    def setUp(self) -> None:
        self.left_player = pong.player.Player('left')
        self.right_player = pong.player.Player('right')
        self.score_board = pong.scoreboard.ScoreBoard(self.left_player, self.right_player)

    def test_should_annotate_left_point(self):
        self.left_player.score = 1
        self.right_player.score = 0

        self.assertEqual(' 1 : 0 ', self.score_board.score())

    def test_should_annotate_right_point(self):
        self.left_player.score = 0
        self.right_player.score = 1

        self.assertEqual(' 0 : 1 ', self.score_board.score())

    def test_left_should_be_winner(self):
        self.left_player.score = 1
        self.right_player.score = 0

        self.assertEqual(' left WON! 1 : 0 ', self.score_board.final_board())

    def test_right_should_be_winner(self):
        self.left_player.score = 0
        self.right_player.score = 1

        self.assertEqual(' right WON! 0 : 1 ', self.score_board.final_board())
import pygame

import pong.config
from pong.config import POINTS_TO_WIN


class ScoreBoard:
    def __init__(self, left_player, right_player):
        self.left_player = left_player
        self.right_player = right_player
        self.target = POINTS_TO_WIN

    def draw(self, scene):
        board = self.score()
        scene.text_renderer.blit(board, pong.config.style_score)

    def score(self):
        return " {0} : {1} ".format(self.left_player.score, self.right_player.score)

    def stop(self):
        return self.left_player.score == self.target or self.right_player.score == self.target

    def winner(self, scene):
        board = self.final_board()
        scene.text_renderer.blit(board, pong.config.style_score)

    def final_board(self):
        if self.left_player.score > self.right_player.score:
            winner = self.left_player
        else:
            winner = self.right_player
        score = self.score()
        return (" {0} WON!" + score).format(winner.name)

Verificamos que todos los tests pasan (ya son 30) y manualmente que el juego muestra el marcador correcto. Con esto estamos listas para hacer un commit y dar por terminada la historia US-4.

La última historia del sprint

Para cerrar el sprint nos queda una historia, que resulta ser un bug:

BUG-1 Al salir del juego cerrando la ventana, se acaba saliendo de la aplicación.

Después de varias veces de haber verificado manualmente el funcionamiento del juego, hemos podido determinar que el problema ocurre si se sale del juego pulsando la combinación de teclas que cierra la ventana. Eso provoca que se salga casi de forma inmediata sin dar opción a la jugadora para pulsar la tecla P y volver a empezar.

Examinemos el código:

        while not done:
            event = pygame.event.wait()
            if event.type in (pygame.KEYDOWN, pygame.KEYUP):
                key_name = pygame.key.name(event.key)
                if key_name == "p":
                    exit_code = self.window.PLAY_AGAIN
                done = True

        return exit_code

El problema parece estar en que “escuchamos” el evento pygame.KEYUP que indica que se ha dejado de pulsar una tecla. Esto podría provocar que estamos recibiendo un evento de ese tipo al soltar la combinación de teclas de cierre de la ventana (Ctrl-W o Cmd-W), lo que hace que la variable de control done se ponga a True.

Así que hacemos este cambio para comprobarlo:

        while not done:
            event = pygame.event.wait()
            if event.type == pygame.KEYDOWN:
                key_name = pygame.key.name(event.key)
                if key_name == "p":
                    exit_code = self.window.PLAY_AGAIN
                done = True

Y ahora funciona tal y como queríamos.

Esto nos permite eliminar el delay que habíamos puesto para intentar evitar (erróneamente) el problema:

import pygame

import pong.config
from pong.app.scene import Scene
from pong.app.window import Window


class EndScene(Scene):
    def __init__(self, window: Window):
        super().__init__(window)

    def run(self):
        self.window.score_board.winner(self)

        self.text_renderer.blit('Game finished', pong.config.style_end_title)
        self.text_renderer.blit('Press P to play again or any other key to exit', pong.config.style_prompt)

        pygame.display.flip()
        done = False
        exit_code = 0

        pygame.event.clear()

        while not done:
            event = pygame.event.wait()
            if event.type == pygame.KEYDOWN:
                key_name = pygame.key.name(event.key)
                if key_name == "p":
                    exit_code = self.window.PLAY_AGAIN
                done = True

        return exit_code

Y también podemos quitarlo en los tests:

import unittest.mock

import pygame

import pong.scenes.endscene
from pong.app.window import Window
from pong.tests import events


class EndSceneTestCase(unittest.TestCase):
    @unittest.mock.patch("pong.scoreboard.ScoreBoard")
    @unittest.mock.patch('pygame.event.wait', return_value=events.any_key_event)
    def test_should_run_fine(self, score_board_mock, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        window.score_board = score_board_mock
        scene = pong.scenes.endscene.EndScene(window)

        pygame.init()
        self.assertEqual(0, scene.run())
        pygame.quit()

    @unittest.mock.patch("pong.scoreboard.ScoreBoard")
    @unittest.mock.patch('pygame.event.wait', return_value=events.p_key_event)
    def test_should_ask_play_againg_when_pressing_p(self, score_board_mock, mock):
        window = pong.app.window.Window(800, 600, 'Test')
        window.score_board = score_board_mock
        scene = pong.scenes.endscene.EndScene(window)

        pygame.init()
        self.assertEqual(window.PLAY_AGAIN, scene.run())
        pygame.quit()


if __name__ == '__main__':
    unittest.main()

Con todo esto, hacemos un commit y entregamos la resolución del bug.

Cierre del sprint

Al completar esta última historia podemos cerrar el sprint. Antes hemos comentado que si nos ha sobrado tiempo podríamos dedicarlo a tareas de mejora del código que hayamos ido detectando.

Nosotras, por el momento, terminamos este artículo aquí.

Temas

good-practices

refactoring

php

testing

tdd

design-patterns

python

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

hexagonal

tips

software-design

ddd

books

bbdd

soft-skills

pulpoCon

oop

javascript

api

typescript

sql

ethics

agile

swift

java