Pong en Python. Recuperarse de un sprint fallido

por Fran Iglesias

En esta entrega veremos cómo prevenir las roturas del sprint mediante la organización de las tareas técnicas en la planificación. También hablaremos de las metas o goals de los sprints.

Tras el fiasco del anterior sprint, he aquí el estado actual del backlog, con la historia que hemos añadido para tratar de recuperar el compromiso no alcanzado.

  • US-12 Se puede seleccionar el modo de juego 1 jug / 2 jug.
  • 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)

La rotura del sprint

Voy a bautizar como “rotura de sprint” a la situación en la que el compromiso de entrega no se ha podido cumplir dentro de un margen de seguridad aceptable. Lo adecuado sería que la entrega del compromiso de sprint se haga como muy tarde el último día del mismo. Sin embargo, pueden haberse dado circunstancias que provoquen un pequeño retraso, por ejemplo, un día en un sprint de dos semanas. Lo prudente sería intentar planificar un poco de menos para tener margen de maniobra ante imprevistos. Es más fácil traer nuevas historias de usuario desde el backlog priorizado si el compromiso de entrega se cumple antes de lo previsto.

Una de las estrategias que puede contribuir a mejorar la entrega es el establecimiento de una o más metas o goals de los sprints. Un goal es un objetivo que define cuál es el valor de negocio principal que vamos a entregar en ese sprint. Si se sigue esta estrategia lo suyo es seleccionar historias de usuario que contribuyan al goal, de modo que evitamos cambios de foco del equipo lo que contribuye a la eficiencia. Además, la existencia de un goal nos permite tener un criterio a la hora de tomar cualquier decisión. Si hacemos esto: ¿estamos contribuyendo al goal del sprint o no? Si la respuesta es negativa, probablemente deberíamos evitar o posponer esa acción.

Prevención de la rotura de sprint

Hemos analizado lo que ha pasado y hemos determinado que nuestro problema ha sido no delimitar correctamente el alcance de las tareas técnicas. Esto es: nuestras User Stories están bastante claras en términos de negocio, lo que es algo bueno, pero no hemos trabajado tanto en definir las tareas técnicas discretas que nos llevarán a completar la historias.

¿A quién corresponde esta responsabilidad? Pues a las desarrolladoras.

En un equipo de desarrollo de alto rendimiento, la Product Owner se responsabiliza de la comunicación con los stakeholders, negociando con ellos la priorización de sus demandas, estableciendo un road map con las líneas principales para un período determinado, que puede ser un trimestre, o incluso algo más. Este roadmap puede concretarse en user stories que se ordenan por su importancia y su potencial de aportar valor de negocio.

Como hemos visto en artículos anteriores, la Product Owner propone un compromiso de entrega por sprint que ha de consensuar con las Desarrolladoras, para poder analizar la relación coste-valor. Se podría decir que la tarea de las Desarrolladoras en la planificación es determinar los costes en forma de tareas técnicas. Ello debería dar lugar a un listado de las mismas que, si bien puede cambiar en función del estado del código o de cómo evolucione, proporciona un mini-roadmap técnico en el que orientarse para organizar el trabajo de desarrollo.

Así que la planificación se mueve con estos dos vectores: uno dirigido por negocio y otro dirigido por el equipo técnico.

En cualquier caso, el resultado debería ser un sprint en el que se han priorizado y seleccionado una serie de historias de usuario, cada una de las cuales tiene su particular desglose de tareas técnicas.

Estas tareas técnicas no tienen que definir los detalles de la implementación. Nos indicarán grosso modo

Nuestro sprint con tareas definidas

Para el siguiente sprint del desarrollo de nuestro Pong!, hemos decidido abordar las siguientes historias:

  • US-12 Se puede seleccionar el modo de juego 1 jug / 2 jug.
  • US-6 La partida se puede configurar para 3 ó 5 sets a 21 puntos con dos puntos de diferencia para el ganador

Vamos a analizar cada una de ellas.

  • US-12 Se puede seleccionar el modo de juego 1 jug / 2 jug.

La situación en que nos habíamos quedado en el sprint anterior es que, si bien era posible hacer partidas de dos jugadoras, no había forma de seleccionar esta modalidad a través de la pantalla de configuración. Esto supone:

  • Mostrar texto indicando el modo de 1 ó 2 jugadoras.
  • Permitir seleccionar el modo de juego pulsando las teclas 1 ó 2.
  • Mostrar la configuración seleccionada.
  • Aplicarla a la partida.

La siguiente historia del sprint es:

  • US-6 La partida se puede configurar para 3 ó 5 sets a 21 puntos con dos puntos de diferencia para el ganador

Esta tarea se presenta un poco más complicada. Se trata de introducir conceptos nuevos en el código. Un punto de partida podría ser el siguiente, que nos sugiere que hay varios pasos principales:

  • Refactorizar el sistema de puntuación actual introduciendo la idea de set, límite de puntos y diferencia mínima.
  • Hacerlo configurable para poder definir el número de sets, puntos y diferencia mínima.
  • Replantear el modo en que se muestran las puntuaciones para reflejar también los sets.

El primero de los pasos, implica intentar definir el sistema de puntuación existente en términos de sets, límite de puntos y diferencia mínima. En nuestro caso, serían 1 set a 5 puntos, pero la diferencia mínima es 0 (por lo que puede acabar en empate).

Hay que tener en cuenta que si no se cumple la diferencia mínima, el límite máximo de puntos se puede superar.

Si tenemos esto, podríamos atacar una primera versión del tercer paso, ya que tendríamos los datos necesarios.

Esto nos permitiría tener una versión mínima viable, potencialmente entregable, que aunque no cumple la totalidad del goal, nos acerca y nos permite obtener un primer feedback.

Ahora es cuando podemos empezar a dar soporte a múltiples sets.

Pero no hay que olvidar que también tenemos que hacer esto configurable. Estrictamente tenemos que hacer configurable el número de sets, que podrá ser 3 ó 5.

Así que podríamos organizar las tareas concretas de este modo:

  • Refactorizar el sistema de puntuación actual introduciendo la idea de set, límite de puntos y diferencia mínima.
    • Aplicar 1 set a 21 puntos, con diferencia de 2 puntos.
  • Replantear el modo en que se muestran las puntuaciones para reflejar también los sets.
    • Ejemplo: puntos (sets) : (sets) puntos
  • Permitir definir el número de sets en la pantalla inicial
    • Mostrar texto indicando el modo de 3 ó 5 sets.
    • Permitir seleccionar el modo de juego pulsando las teclas 3 ó 5.
    • Mostrar la configuración seleccionada.
    • Aplicarla a la partida.
  • Replantear el modo en que se muestran las puntuaciones para reflejar también los sets.
    • Si es necesario, corregir el modo en que se muestra la puntuación durante la partida
    • Al final de la partida mostrar el detalle de los puntos por set

Esto nos muestra que esta historia de usuario es bastante compleja. Es posible que sea difícil llegar a completarla en este sprint. ¿Cómo podemos manejar esto y ayudar a garantizar un compromiso de entrega? Tenemos dos opciones, por lo menos:

  • Cambiar la prioridad y poner esta historia más compleja como primera del sprint, lo que debería garantizar que se conseguirá.
  • Dividir esta historia en dos partes, manteniendo la misma prioridad, para garantizar al menos la entrega de un MVP que satisfaga a negocio. De este modo, se podría asegurar la US-12, y la primera parte de la US-6 en el sprint.

El problema es que US-12 es más prioritaria que US-6 dado que está pendiente del anterior sprint. Por tanto, lo que vamos a hacer es partir la US-6 en dos historias más manejables, la primera de ellas nos permitirá tener un MVP y la segunda será una iteración que proporcionará la funcionalidad completa. Quedan así:

  • US-6A. La partida tendrá 3 set a 21 puntos y se gana con dos de diferencia.
    • Refactorizar el sistema actual para introducir los conceptos de set, límite de puntos y diferencia mínima.
    • Permitir jugar tres sets.
    • Mostrar los sets en el marcador durante el partido.
    • Mostrar los sets en el marcador final.
  • US-6B. Permitir configurar el número de sets en la pantalla inicial
    • Mostrar texto indicando el modo de 3 ó 5 sets.
    • Permitir seleccionar el modo de juego pulsando las teclas 3 ó 5.
    • Mostrar la configuración seleccionada.
    • Aplicarla a la partida.
    • Al final de la partida mostrar el detalle de los puntos en cada set.

El nuevo compromiso de sprint será, entonces:

  • US-12 Se puede seleccionar el modo de juego 1 jug / 2 jug.
  • US-6A. La partida tendrá 3 set a 21 puntos y se gana con dos de diferencia.

La US-6B quedaría para el siguiente sprint, con la opción de empezar a trabajar en ella si terminamos con tiempo.

¿Podemos definir un goal del sprint? En este caso, conseguir cada una de las historias supone un goal del sprint. En el caso de sprints con más historias, es posible que uno o dos goals puedan dar sentido a todas ellas.

US-12 Se puede seleccionar el modo de juego 1 jug / 2 jug.

Examinamos el estado actual del código y observamos que ya tenemos soporte básico para esta prestación y solo tendríamos que permitir la configuración. De hecho, ya existe una prestación similar, relativa a la elección de lado de la pantalla del jugador humano. Esto nos viene bien porque podremos tener una solución relativamente rápida de la historia.

Según esto tenemos qué:

  • Añadir un método a Game para ajustar el modo de juego
  • Modificar StartScene para mostrar el estado actual y dar soporte a la pulsación de las teclas para ajustar el modo

Vamos a ello. Así quedaría Game.py:

import pong.config


class Game(object):

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

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

    def set_game_mode(self, game_mode):
        self.game_mode = game_mode

El soporte en StartScene se podría hacer de la siguiente manera:

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')
                elif key_name == '1':
                    self.window.game.set_game_mode(1)
                elif key_name == '2':
                    self.window.game.set_game_mode(2)
                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)
            self.text_renderer.blit("Players: 1/2 ({0}) ".format(self.window.game.game_mode), pong.config.style_config_players)

            pygame.display.flip()

        return 0

Hemos tenido que añadir una configuración extra de estilos para mostrar el nuevo texto en config.py:

style_config_players = {'font_size': 20, 'horizontal': 'right', 'vertical': 'middle', 'color': white,
                'background': 'transparent'}

Con estos cambios damos soporte la posibilidad de configurar el número de jugadores y podríamos entregar ya la historia. Como ha sido relativamente sencilla aprovechamos que tenemos un poco de margen para hacer algún pequeño refactor si tenemos oportunidad. Parece claro que el sistema de estilos es un poco limitado, pero es evidente que arreglarlo no contribuye al goal del sprint. Así que nos limitaremos a los archivos y partes de código que hayamos tenido que tocar.

Finalmente, desplegamos los cambios y damos por terminada la historia con este commit.

US-6A. La partida tendrá 3 set a 21 puntos y se gana con dos de diferencia.

Al haber desglosado la historia en tareas más específicas, es más fácil de abordar y las acciones más acotadas. Por ejemplo, el primer paso sería un refactor, pero tenemos un objetivo muy específico lo que nos ayudará a saber cuándo tendríamos que parar.

Refactorizar el sistema actual para introducir los conceptos de set, límite de puntos y diferencia mínima.

En este punto, la situación es que cada Player lleva su score, que en este momento no es más que un entero. ScoreBoard se encarga de mostrar el resultado obteniéndolo de los Players. Parece que tendría sentido crear un objeto Score, que tenga los campos necesarios y devuelva la información que necesita ScoreBoard.

Uno de los problemas que tenemos ahora es que ScoreBoard accede directamente a la propiedad score de Player, por lo que un primer punto podría ser encapsular en un método, de forma que Player controla cómo le entrega la información a ScoreBoard. De este modo, además, Player puede cambiar internamente la representación de los puntos.

Así queda Player

from pong.game.pad import Pad
from pong.goal import Goal


class Player:
    def __init__(self, name, side, engine, speed=1):
        self.name = name
        self._score = 0
        self.engine = engine
        self.side = side
        self.pad = Pad(self.side, speed, self.engine)
        if side == 'left':
            self.goal = Goal(790, self)
        else:
            self.goal = Goal(0, self)

    def win_point(self):
        self._score += 1

    def handle(self, event):
        self.pad.handle(event)

    def score(self):
        return self._score

Y así ScoreBoard:

import pong.config
from pong.config import POINTS_TO_WIN


class ScoreBoard:
    def __init__(self, player_one, player_two):
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        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.score(), self.right.score())

    def end_of_game(self):
        return self.left.score() == self.target or self.right.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.score() > self.right.score():
            winner = self.left
        else:
            winner = self.right
        score = self.score()
        return (" {0} WON!" + score).format(winner.name)

Esto ya nos permite hacer que Player._score pueda cambiar sin afectar a ScoreBoard y prepararnos para introducir el sistema de puntuación. Al final de la historia de usuario veremos que habrá más afectación, pero ahora nos viene bien ir poco a poco.

Por una parte tenemos el problema de que ScoreBoard se encarga de varias cosas, ya que no solo visualiza el resultado, sino que también controla el final de la partida. Por tanto, en algún momento tendremos que sacarlo a su propio objeto.

De momento, vamos a introducir el objeto Score, que nos servirá para que cada jugador mantenga la información de su puntuación.

class Score(object):
    def __init__(self):
        self._points = 0
        self._score = None

    def win_point(self):
        self._points += 1

    def points(self):
        return self._points

    def new_set(self):
        if self._score is None:
            self._score = []
        self._score.append(self._points)
        self._points = 0

    def score(self):
        return self._score

    def end_match(self):
        self.new_set()

La clase Score va recogiendo los puntos de cada set. Cuando se inicia un set nuevo o cuando se acaba el partido, guarda el número de puntos y lo resetea. Se puede ver un ejemplo en este test:

from unittest import TestCase

from pong.game.score import Score


class TestScore(TestCase):
    def setUp(self):
        self.score = Score()

    def test_it_adds_points(self):
        self.score.win_point()
        self.assertEqual(1, self.score.points())

    def test_it_adds_sets(self):
        self.score.new_set()
        self.assertEqual(0, self.score.points())
        self.assertEqual([0], self.score.score())

    def test_simulate_a_game(self):
        self.win_many_points(3)
        self.score.new_set()
        self.win_many_points(5)
        self.score.end_match()
        self.assertEqual([3, 5], self.score.score())

    def win_many_points(self, qty):
        for i in range(qty):
            self.score.win_point()

Por el momento, podemos introducirla en Player sin apenas tocar nada:

from pong.game.score import Score
from pong.game.pad import Pad
from pong.goal import Goal


class Player:
    def __init__(self, name, side, engine, speed=1):
        self.name = name
        self._score = Score()
        self.engine = engine
        self.side = side
        self.pad = Pad(self.side, speed, self.engine)
        if side == 'left':
            self.goal = Goal(790, self)
        else:
            self.goal = Goal(0, self)

    def win_point(self):
        self._score.win_point()

    def handle(self, event):
        self.pad.handle(event)

    def score(self):
        return self._score.points()

El test de ScoreBoard, necesita un cambio para seguir pasando, cambio que le viene muy bien, por otra parte, ya que aumenta su legibilidad:

from unittest import TestCase

import pong.config
from pong.ball import Ball
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.player import Player
from pong.scoreboard import ScoreBoard


class TestScoreBoard(TestCase):

    def setUp(self) -> None:
        ball = Ball(pong.config.white, 10)
        engine = ComputerControlEngine(ball)
        self.left_player = Player('left', 'left', engine)
        self.right_player = Player('right', 'right', engine)
        self.score_board = ScoreBoard(self.left_player, self.right_player)

    def test_should_annotate_left_point(self):
        self.left_player.win_point()

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

    def test_should_annotate_right_point(self):
        self.right_player.win_point()

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

    def test_left_should_be_winner(self):
        self.left_player.win_point()

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

    def test_right_should_be_winner(self):
        self.right_player.win_point()

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

Con estos cambios, podríamos considerar completada la tarea, por lo que hacemos commit tras verificar que los tests pasan y el juego mantiene la funcionalidad.

Permitir jugar tres sets.

En la tarea anterior nos hemos limitado a cambiar la forma en que se registra la puntuación de cada jugadora, sin alterar nada de la funcionalidad existente. Es obvio que aún queda mucho trabajo para cambiar el sistema de puntuación y llegar a permitir partidas a tres sets que es lo que nos pide la historia.

Nos hace falta incluir varias cosas:

  • Necesitamos algún servicio que gestione las puntuaciones de los jugadores y decida cuando se ha ganado un set y cuando se gana un partido.
  • El control del final del partido dejará de estar en ScoreBoard, para pasar al nuevo servicio.
  • Score tiene que pode registrar los sets ganados, para que ese servicio pueda comparar el número de sets.
  • Es necesario distinguir entre el final de un set, que nos lleva de nuevo al principio de la escena de juego, del final del partido, que nos lleva a la pantalla de despedida.

El nuevo sistema de puntuación tiene que manejar algunas reglas:

  • Se gana un set con una diferencia mínima de dos puntos.
  • En una partida a tres sets, gana la jugadora que gana dos sets, por lo que el partido puede terminar sin jugarse todos los sets.
  • En una partida a cinco sets, gana la que llegue primero a 3 sets ganados.

Un enfoque posible es empezar extrayendo la responsabilidad del control de puntuación y finalización del juego fuera de ScoreBoard, que quedaría como un visor de puntuaciones.

A falta de un nombre mejor, llamaré a este servicio ScoreManager. En principio, podemos partir del test existente de ScoreBoard, para extraer la funcionalidad básica, haciendo que ScoreBoard use ScoreManager como su proveedor de puntuaciones. Este el código para empezar a trabajar:

import pong.config
from pong.config import POINTS_TO_WIN
from pong.game.scoring.score_manager import ScoreManager


class ScoreBoard:
    def __init__(self, player_one, player_two):
        self.score_manager = ScoreManager(player_one, player_two)
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        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.score(), self.right.score())

    def end_of_game(self):
        return self.left.score() == self.target or self.right.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.score() > self.right.score():
            winner = self.left
        else:
            winner = self.right
        score = self.score()
        return (" {0} WON!" + score).format(winner.name)

Y esto es nuestro ScoreManager inicial:

from pong.config import POINTS_TO_WIN


class ScoreManager(object):

    def __init__(self, player_one, player_two) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        self.target = POINTS_TO_WIN

Este cambio no rompe el test, por lo que podemos empezar a mover las funcionalidades. Ya podemos ver que necesitaremos mejorar algunas cosas, como la forma en que pasamos el límite de puntuación, pero ahora mismo nuestro objetivo es cambiar la estructura, luego veremos cómo mejorarla y, posteriormente, enriqueceremos el comportamiento para lograr el objetivo de la tarea.

En un primer momento, movemos lo que resulta más sencillo:

import pong.config
from pong.config import POINTS_TO_WIN
from pong.game.scoring.score_manager import ScoreManager


class ScoreBoard:
    def __init__(self, player_one, player_two):
        self.score_manager = ScoreManager(player_one, player_two)
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        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.score(), self.right.score())

    def end_of_game(self):
        return self.score_manager.end_of_game()

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

    def final_board(self):
        winner = self.score_manager.winner()

        score = self.score()
        return (" {0} WON!" + score).format(winner.name)
from pong.config import POINTS_TO_WIN


class ScoreManager(object):

    def __init__(self, player_one, player_two) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        self.target = POINTS_TO_WIN

    def end_of_game(self):
        return self.left.score() == self.target or self.right.score() == self.target

    def winner(self):
        if self.left.score() > self.right.score():
            return self.left

        return self.right

Esto ha sido una extracción bastante obvia. Lo que nos quedaría es mover el método score, que utiliza los dos objetos player. Una forma sería hacer que ScoreManager devuelta una simple tupla con los puntos de cada jugadora.

import pong.config
from pong.game.scoring.score_manager import ScoreManager


class ScoreBoard:
    def __init__(self, player_one, player_two):
        self.score_manager = ScoreManager(player_one, player_two)

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

    def score(self):
        return " {0} : {1} ".format(self.score_manager.score()[0], self.score_manager.score()[1])

    def end_of_game(self):
        return self.score_manager.end_of_game()

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

    def final_board(self):
        winner = self.score_manager.winner()

        score = self.score()
        return (" {0} WON!" + score).format(winner.name)
from pong.config import POINTS_TO_WIN


class ScoreManager(object):

    def __init__(self, player_one, player_two) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        self.target = POINTS_TO_WIN

    def end_of_game(self):
        return self.left.score() == self.target or self.right.score() == self.target

    def winner(self):
        if self.left.score() > self.right.score():
            return self.left

        return self.right

    def score(self):
        return self.left.score(), self.right.score()

Ya casi estamos. Ahora necesitamos inyectar la dependencia ScoreManager, de forma que se inicialice fuera de ScoreBoard y podamos usarla para que sea la responsable de controlar el final del juego. Esto supone cambios en varios lugares, entre ellos GameScene.

import pygame
from pygame.sprite import Group
from pygame.time import Clock

import pong.config
from pong.app.scene import Scene
from pong.app.window import Window
from pong.ball import Ball
from pong.border import Border
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.game.control.keyboard_control_engine import KeyboardControlEngine
from pong.player import Player
from pong.scoreboard import ScoreBoard
from pong.game.scoring.score_manager import ScoreManager


class GameScene(Scene):
    def __init__(self, window: Window):
        super().__init__(window)
        self.all_sprites = Group()
        self.goals = Group()
        self.pads = Group()
        self.borders = Group()
        self.ball = Ball(pong.config.yellow, 10)
        self.all_sprites.add(self.ball)

    def run(self):

        self.prepare_borders()

        player_one_side = 'left'
        player_two_side = 'right'
        player_one_speed = self.window.game.human_speed
        player_two_speed = self.window.game.human_speed
        player_one_engine = self.player_engine(pong.config.left_keys)
        player_two_engine = self.player_engine(pong.config.right_keys)

        if self.window.game.game_mode == 1:
            player_one_side = self.window.game.side_preference
            if player_one_side == 'left':
                player_two_side = 'right'
            else:
                player_two_side = 'left'
            player_one_speed = self.window.game.human_speed
            player_two_speed = self.window.game.computer_speed
            player_two_engine = self.player_engine(())
        elif self.window.game.game_mode == 0:
            player_one_speed = self.window.game.computer_speed
            player_two_speed = self.window.game.computer_speed
            player_one_engine = self.player_engine(())
            player_two_engine = self.player_engine(())

        player_one = Player('human', player_one_side, player_one_engine, player_one_speed)
        player_two = Player('computer', player_two_side, player_two_engine, player_two_speed)

        self.window.score_manager = ScoreManager(player_one, player_two)
        self.window.score_board = ScoreBoard(self.window.score_manager)

        player_one.pad.borders = self.borders
        player_two.pad.borders = self.borders

        self.all_sprites.add(player_one.goal)
        self.all_sprites.add(player_two.goal)

        self.goals.add(player_one.goal)
        self.goals.add(player_two.goal)

        self.all_sprites.add(player_one.pad)
        self.all_sprites.add(player_two.pad)

        self.pads.add(player_one.pad)
        self.pads.add(player_two.pad)

        self.ball.pads = self.pads
        self.ball.borders = self.borders
        self.ball.goals = self.goals

        # Game loop
        clock = Clock()
        pygame.time.set_timer(pong.config.COMPUTER_MOVES_EVENT, pong.config.COMPUTER_MOVES_TIMER_MS)
        done = False

        while not done:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    done = True
                player_one.handle(event)
                player_two.handle(event)

            pygame.event.pump()
            self.all_sprites.update()

            # Manage goals
            self.ball.manage_goals()

            # Game draw
            self.window.screen.fill(pong.config.green)
            self.window.score_board.draw(self)
            self.all_sprites.draw(self.window.screen)

            # Screen update
            pygame.display.flip()

            if self.window.score_manager.end_of_game():
                done = True

            clock.tick(pong.config.FPS)

        return 0

    def player_engine_for_second_player(self, keys):
        if self.window.game.game_mode == 1 or self.window.game.game_mode == 0:
            return ComputerControlEngine(self.ball)

        return KeyboardControlEngine(keys)

    def player_engine(self, keys):
        if len(keys) == 0:
            return ComputerControlEngine(self.ball)

        return KeyboardControlEngine(keys)

    def prepare_borders(self):
        border_top = Border(0)
        border_bottom = Border(590)
        self.all_sprites.add(border_top)
        self.all_sprites.add(border_bottom)
        self.borders.add(border_top)
        self.borders.add(border_bottom)

Realmente son pocos los cambios. Básicamente instanciar el ScoreManager y pasarlo a ScoreBoard para que pueda usarlo al mostrar los marcadores. Y, finalmente, hacer que sea ScoreManager quien controle el final del partido.

En todo caso, dado que ya tenemos bastantes cambios y con esto estamos más preparadas para abordar el cambio de comportamiento, haremos un commit, tras el cual nos ocuparemos de gestionar las partidas de tres sets.

Para poder hacer partidas a varios sets tenemos que:

  • permitir que Score pueda llevar la cuenta de los sets ganados, de modo que ScoreManager pueda usar esa información
  • igualmente, puesto que Score es interno a Player, implementar el comportamiento “ganar un set”, así como devolver tanto los puntos ganados como los sets
  • distinguir entre finalizar set y finalizar partido, cosa de la que se encargará ScoreManager.

Usaré un enfoque outside-in desde el punto de vista de ScoreManager, es decir, desarrollaré esta funcionalidad a partir de tests de ScoreManager, introduciendo los cambios que necesite en Player y Score. ScoreManager aún no tiene tests, por lo que aprovechamos para introducirlos.

Este primer test verifica el estado actual del cálculo del ganador:

from unittest import TestCase

import pong.config
from pong.ball import Ball
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.game.scoring.score_manager import ScoreManager
from pong.player import Player


class TestScoreManager(TestCase):
    def setUp(self) -> None:
        ball = Ball(pong.config.white, 10)
        engine = ComputerControlEngine(ball)
        self.left_player = Player('left', 'left', engine)
        self.right_player = Player('right', 'right', engine)
        self.score_manager = ScoreManager(self.left_player, self.right_player)

    def testLeftPlayerWinsGame(self):
        self.left_player.win_point()
        self.left_player.win_point()
        self.left_player.win_point()
        self.left_player.win_point()
        self.left_player.win_point()
        self.assertEqual(self.left_player, self.score_manager.winner())

Esto evidencia varios problemas. El más importante es que la definición del límite de puntos y sets está todavía dentro de ScoreManager o no está disponible aún, por lo que tenemos que permitir que eso pueda configurarse para cada caso.

Para expresar la estructura del partido, usaremos inicialmente una tupla que será (sets, puntos). De paso, haremos que sea más fácil generar casos de prueba. Este es el nuevo test.

from unittest import TestCase

import pong.config
from pong.ball import Ball
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.game.scoring.score_manager import ScoreManager
from pong.player import Player


class TestScoreManager(TestCase):
    def setUp(self) -> None:
        ball = Ball(pong.config.white, 10)
        engine = ComputerControlEngine(ball)
        self.left_player = Player('left', 'left', engine)
        self.right_player = Player('right', 'right', engine)

    def testLeftPlayerWinsGame(self):
        self.score_manager = ScoreManager(self.left_player, self.right_player, (1, 5))
        self.scoreAfterSet(5, 0)
        self.assertEqual(self.left_player, self.score_manager.winner())

    def scoreAfterSet(self, left, right):
        for i in range(left):
            self.left_player.win_point()
        for i in range(right):
            self.right_player.win_point()

El cambio de constructor, nos obliga a introducir un par de cambios en GameScene y en el tests de ScoreBoard:

        self.window.score_manager = ScoreManager(player_one, player_two, (1, POINTS_TO_WIN))
        self.window.score_board = ScoreBoard(self.window.score_manager)

De este modo, con una estructura de puntos del partido (1, 5) reproducimos el comportamiento actual. Ahora podemos dirigir la implementación de este comportamiento con tests.

Por ejemplo, vamos a empezar asegurando que solo se puede ganar un set con más de un punto de diferencia. Esto nos obliga a introducir el concepto de final de set (end_of_set en el código). Este test nos sirve para conseguirlo:

from unittest import TestCase

import pong.config
from pong.ball import Ball
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.game.scoring.score_manager import ScoreManager
from pong.player import Player


class TestScoreManager(TestCase):
    def setUp(self) -> None:
        ball = Ball(pong.config.white, 10)
        engine = ComputerControlEngine(ball)
        self.left_player = Player('left', 'left', engine)
        self.right_player = Player('right', 'right', engine)

    def testLeftPlayerWinsGame(self):
        self.score_manager = ScoreManager(self.left_player, self.right_player, (1, 5))
        self.scoreAfterSet(5, 0)
        self.assertEqual(self.left_player, self.score_manager.winner())

    def testShouldNotWinASetIfMinimumPointsNotReached(self):
        self.score_manager = ScoreManager(self.left_player, self.right_player, (1, 5))
        self.scoreAfterSet(4, 0)
        self.assertFalse(self.score_manager.end_of_set())

    def testShouldWinASetByTwoOrMorePoints(self):
        self.score_manager = ScoreManager(self.left_player, self.right_player, (1, 5))
        self.scoreAfterSet(5, 0)
        self.assertTrue(self.score_manager.end_of_set())
        self.scoreAfterSet(5, 3)
        self.assertTrue(self.score_manager.end_of_set())
        self.scoreAfterSet(7, 5)
        self.assertTrue(self.score_manager.end_of_set())

    def testShoulfNotWinSetIfNoEnoughDifference(self):
        self.score_manager = ScoreManager(self.left_player, self.right_player, (1, 5))
        self.scoreAfterSet(5, 4)
        self.assertFalse(self.score_manager.end_of_set())
        self.scoreAfterSet(6, 5)
        self.assertFalse(self.score_manager.end_of_set())
        self.scoreAfterSet(7, 6)
        self.assertFalse(self.score_manager.end_of_set())

    def scoreAfterSet(self, left, right):
        self.left_player.new_set()
        self.right_player.new_set()
        for i in range(left):
            self.left_player.win_point()
        for i in range(right):
            self.right_player.win_point()

Y así cambia ScoreManager:



class ScoreManager(object):

    def __init__(self, player_one, player_two, match) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        self.match = match

    def end_of_game(self):
        return self.left.score() == self.match[1] or self.right.score() == self.match[1]

    def end_of_set(self):
        difference = abs(self.left.score() - self.right.score())
        winner_points = max(self.left.score(), self.right.score())
        return difference >= 2 and winner_points >= self.match[1]

    def winner(self):
        if self.left.score() > self.right.score():
            return self.left

        return self.right

    def score(self):
        return self.left.score(), self.right.score()

Ahora vamos a cambiar end_of_game para que se calcule a partir del número de sets ganados. De momento, con un solo set nos bastaría con delegar en end_of_set, aunque no es el último trabajo que nos queda por hacer.

Pero lo mejor será dirigir el desarrollo mediante tests que nos ayuden a definir las reglas. Solo mostraré los test relacionados con este comportamiento, vamos allá:

from unittest import TestCase

import pong.config
from pong.ball import Ball
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.game.scoring.score_manager import ScoreManager
from pong.player import Player


class TestScoreManager(TestCase):
    def setUp(self) -> None:
        ball = Ball(pong.config.white, 10)
        engine = ComputerControlEngine(ball)
        self.left_player = Player('left', 'left', engine)
        self.right_player = Player('right', 'right', engine)

    def testShouldWindOneSetMatch(self):
        self.score_manager = ScoreManager(self.left_player, self.right_player, (1, 5))
        self.scoreAfterSet(5, 0)
        self.assertTrue(self.score_manager.end_of_game())

    def scoreAfterSet(self, left, right):
        self.left_player.new_set()
        self.right_player.new_set()
        for i in range(left):
            self.left_player.win_point()
        for i in range(right):
            self.right_player.win_point()

De momento, end_of_game delega en end_of_set:



class ScoreManager(object):

    def __init__(self, player_one, player_two, match) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one

        self.match = match

    def end_of_game(self):
        return self.end_of_set()

    def end_of_set(self):
        difference = abs(self.left.score() - self.right.score())
        winner_points = max(self.left.score(), self.right.score())
        return difference >= 2 and winner_points >= self.match[1]

    def winner(self):
        if self.left.score() > self.right.score():
            return self.left

        return self.right

    def score(self):
        return self.left.score(), self.right.score()

Antes de seguir, haremos un refactor para usar el cómputo de sets. Tal como está ahora mismo será muy difícil conseguir hacer un test que falle y nos fuerce a desarrollar la funcionalidad. Además, el final del partido viene dado por la puntuación en sets.

import math


class ScoreManager(object):

    def __init__(self, player_one, player_two, match) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one
        self.match = match

    def end_of_game(self):
        best = max(self.left.sets(), self.right.sets())
        return best >= 2

    def end_of_set(self):
        difference = abs(self.left.score() - self.right.score())
        winner_points = max(self.left.score(), self.right.score())
        return difference >= 2 and winner_points >= self.match[1]

    def winner(self):
        if self.left.score() > self.right.score():
            return self.left

        return self.right

    def score(self):
        return self.left.score(), self.right.score()

Para lo cual nos hemos ayudado de estos tests, que han quedado así una vez refactorizados:

from unittest import TestCase

import pong.config
from pong.ball import Ball
from pong.game.control.computer_control_engine import ComputerControlEngine
from pong.game.scoring.score_manager import ScoreManager
from pong.player import Player


class TestScoreManager(TestCase):
    def testLeftPlayerWinsSet(self):
        self.scoreAfterSet(5, 0)
        self.assertEqual(self.left_player, self.score_manager.winner())

    def testShouldNotWinASetIfMinimumPointsNotReached(self):
        self.scoreAfterSet(4, 0)
        self.assertFalse(self.score_manager.end_of_set())

    def testShouldWinASetByTwoOrMorePoints(self):
        self.scoreAfterSet(5, 0)
        self.assertTrue(self.score_manager.end_of_set())
        self.scoreAfterSet(5, 3)
        self.assertTrue(self.score_manager.end_of_set())
        self.scoreAfterSet(7, 5)
        self.assertTrue(self.score_manager.end_of_set())

    def testShouldNotWinSetIfNoEnoughDifference(self):
        self.scoreAfterSet(5, 4)
        self.assertFalse(self.score_manager.end_of_set())
        self.scoreAfterSet(6, 5)
        self.assertFalse(self.score_manager.end_of_set())
        self.scoreAfterSet(7, 6)
        self.assertFalse(self.score_manager.end_of_set())

    def testShouldEndGame(self):
        self.scoreInSets(2, 0)
        self.assertTrue(self.score_manager.end_of_game())
        self.scoreInSets(2, 1)
        self.assertTrue(self.score_manager.end_of_game())
        self.scoreInSets(3, 0)
        self.assertTrue(self.score_manager.end_of_game())

    def test_should_not_end_game(self):
        self.scoreInSets(0, 0)
        self.assertFalse(self.score_manager.end_of_game())
        self.scoreInSets(1, 1)
        self.assertFalse(self.score_manager.end_of_game())
        self.scoreInSets(1, 0)
        self.assertFalse(self.score_manager.end_of_game())

    def scoreAfterSet(self, left, right):
        self.left_player = self.preparePlayer()
        self.right_player = self.preparePlayer()
        for i in range(left):
            self.left_player.win_point()
        for i in range(right):
            self.right_player.win_point()
        self.score_manager = ScoreManager(self.left_player, self.right_player, (3, 5))

    def scoreInSets(self, left, right):
        self.left_player = self.preparePlayer()
        self.right_player = self.preparePlayer()
        for i in range(left):
            self.left_player.win_set()
        for i in range(right):
            self.right_player.win_set()
        self.score_manager = ScoreManager(self.left_player, self.right_player, (3, 5))

    @staticmethod
    def preparePlayer():
        ball = Ball(pong.config.white, 10)
        engine = ComputerControlEngine(ball)
        return Player('left', 'left', engine)

Hay algunos pequeños cambios en otros objetos que puedes ver en el commit. Seguramente este código puede evolucionar un poco más, pero nos interesa avanzar ahora en el resto de la historia.

El punto que nos falta ahora es gestionar los finales de set y los finales de partido. Esencialmente, en cada ciclo de juego debemos comprobar si se ha alcanzado el final del set, en cuyo caso tendremos que iniciar uno nuevo, y si hemos alcanzado el final del partido, saliendo a la pantalla final.

Para centrarnos en esta parte, primero haremos un commit con los cambios que tenemos hasta ahora dado que no rompen la funcionalidad existente.

Finalmente, vamos a analizar GameScene para ver cómo tendríamos que dar soporte a la posibilidad de tener 3 sets en un partido.

Puede que la clave esté aquí:

        done = False

        while not done:

El bucle de juego es controlado por una variable done que se pondrá en True cuando se alcance el final del partido. Una posible solución, entonces, sería un doble bucle while:

  • El bucle exterior espera a que se termine el partido
  • El bucle interior espera a que se termine cada set

De entrada, nos conviene cambiar el nombre de la variable done a otro más expresivo, como end_of_match, mientras que la variable del bucle interior puede ser end_of_set.

        end_of_match = False

        while not end_of_match:

Otra cosa que tendremos que implementar es la adjudicación de los sets a la jugadora que los gane en cada caso, algo que todavía no tenemos y que bien puede ser responsabilidad de ScoreManager.

class ScoreManager(object):

    def __init__(self, player_one, player_two, match) -> None:
        if player_one.side == 'left':
            self.left = player_one
            self.right = player_two
        else:
            self.left = player_two
            self.right = player_one
        self.match = match

    def end_of_game(self):
        best = max(self.left.sets(), self.right.sets())
        return best >= 2

    def end_of_set(self):
        difference = abs(self.left.score() - self.right.score())
        winner_points = max(self.left.score(), self.right.score())
        return difference >= 2 and winner_points >= self.match[1]

    def winner(self):
        if self.left.score() > self.right.score():
            return self.left

        return self.right

    def score(self):
        return self.left.score(), self.right.score()

    def end_set(self):
        if self.left.score() > self.right.score():
            self.left.win_set()
            self.right.new_set()
        else:
            self.right.win_set()
            self.left.new_set()

El bucle de juego quedaría así:

        # Game loop
        clock = Clock()
        pygame.time.set_timer(pong.config.COMPUTER_MOVES_EVENT, pong.config.COMPUTER_MOVES_TIMER_MS)
        end_of_match = False

        while not end_of_match:

            end_of_set = False
            while not end_of_set:
                for event in pygame.event.get():
                    if event.type == pygame.QUIT:
                        end_of_match = True
                    player_one.handle(event)
                    player_two.handle(event)

                pygame.event.pump()
                self.all_sprites.update()

                # Manage goals
                self.ball.manage_goals()

                # Game draw
                self.window.screen.fill(pong.config.green)
                self.window.score_board.draw(self)
                self.all_sprites.draw(self.window.screen)

                # Screen update
                pygame.display.flip()

                if self.window.score_manager.end_of_set():
                    end_of_set = True
                    self.window.score_manager.end_set()
                    if self.window.score_manager.end_of_game():
                        end_of_match = True

                clock.tick(pong.config.FPS)

        return 0

Con estos cambios ya es posible jugar partidos a tres sets. El problema ahora es que los tests de App y Scene ahora juegan un partido entero. ¿Por qué ocurre esto? Posiblemente es debido al anidamiento de bucles, así que introducimos un break que fuerce la salida de la ejecución si se acaba el partido:

        # Game loop
        clock = Clock()
        pygame.time.set_timer(pong.config.COMPUTER_MOVES_EVENT, pong.config.COMPUTER_MOVES_TIMER_MS)
        end_of_match = False

        while not end_of_match:

            end_of_set = False
            while not end_of_set:
                for event in pygame.event.get():
                    if event.type == pygame.QUIT:
                        end_of_match = True
                    player_one.handle(event)
                    player_two.handle(event)

                pygame.event.pump()
                self.all_sprites.update()

                # Manage goals
                self.ball.manage_goals()

                # Game draw
                self.window.screen.fill(pong.config.green)
                self.window.score_board.draw(self)
                self.all_sprites.draw(self.window.screen)

                # Screen update
                pygame.display.flip()

                if self.window.score_manager.end_of_set():
                    end_of_set = True
                    self.window.score_manager.end_set()
                    if self.window.score_manager.end_of_game():
                        end_of_match = True

                if end_of_match:
                    break
                clock.tick(pong.config.FPS)

        return 0

Otro problema que ha aparecido es que se juegan dos sets a pesar de que todavía tenemos uno como límite. Después de investigar y hacer unas pruebas, finalmente, el error está en cómo se asignan las variables de control de los bucles:

        # Game loop
        clock = Clock()
        pygame.time.set_timer(pong.config.COMPUTER_MOVES_EVENT, pong.config.COMPUTER_MOVES_TIMER_MS)
        end_of_match = False

        while not end_of_match:

            self.window.score_manager.new_set()
            end_of_set = False
            while not end_of_set:
                for event in pygame.event.get():
                    if event.type == pygame.QUIT:
                        end_of_match = True
                        break
                    player_one.handle(event)
                    player_two.handle(event)

                pygame.event.pump()
                self.all_sprites.update()

                # Manage goals
                self.ball.manage_goals()

                # Game draw
                self.window.screen.fill(pong.config.green)
                self.window.score_board.draw(self)
                self.all_sprites.draw(self.window.screen)

                # Screen update
                pygame.display.flip()

                if self.window.score_manager.end_of_set():
                    end_of_set = True
                    self.window.score_manager.end_set()

                    if self.window.score_manager.end_of_game():
                        end_of_match = True

                clock.tick(pong.config.FPS)
        return 0

De hecho, configuramos el partido para que tenga 3 sets y cerramos la tarea con este commit.

Mostrar los sets en el marcador durante el partido.

Como era de esperar, jugar el partido a tres sets sin cambiar la forma en que se muestra la información en el marcador es bastante incómodo. La siguiente tarea abordará este problema.

Sin embargo ha surgido otra cuestión que tendremos que tener en cuenta: no hay pausa o algo que separe los sets entre ellos. Lo suyo es anotar esta carencia en el backlog para preparar una nueva historia que acometer más adelante aunque ahora podamos preparar algún tipo de apaño rápido.

Probamos una primera solución:

import pong.config


class ScoreBoard:
    def __init__(self, score_manager):
        self.score_manager = score_manager

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

    def score(self):
        return "({2}) {0} : {1} ({3})".format(
            self.score_manager.score()[0],
            self.score_manager.score()[1],
            self.score_manager.sets()[0],
            self.score_manager.sets()[1],
        )

    def end_of_game(self):
        return self.score_manager.end_of_game()

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

    def final_board(self):
        winner = self.score_manager.winner()

        score = self.score()
        return (" {0} WON!" + score).format(winner.name)

El problema es que esta visualización queda bastante horrorosa. ya que ocupa un montón de espacio y todos los puntos aparecen al mismo nivel. Se sugiere quitar el fondo blanco, para que el marcador aparezca flotando y, por otro lado, el marcador de sets se pondrá en un tamaño un poco menor debajo del principal.

La solución es relativamente sencilla:

import pong.config


class ScoreBoard:
    def __init__(self, score_manager):
        self.score_manager = score_manager

    def draw(self, scene):
        scene.text_renderer.blit(self.points(), pong.config.style_score)
        scene.text_renderer.blit(self.sets(), pong.config.style_sets)

    def points(self):
        return "{0} : {1}".format(
            self.score_manager.score()[0],
            self.score_manager.score()[1],
        )

    def sets(self):
        return "{0} : {1}".format(
            self.score_manager.sets()[0],
            self.score_manager.sets()[1],
        )

    def end_of_game(self):
        return self.score_manager.end_of_game()

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

    def final_board(self):
        winner = self.score_manager.winner()

        score = self.points()
        return (" {0} WON!" + score).format(winner.name)

Hemos cambiado un poco los estilos definidos en config.py

dark_green = (22, 64, 24)


style_score = {'font_size': 64, 'horizontal': 'center', 'vertical': 'top', 'color': dark_green, 'background': 'transparent'}
style_sets = {'font_size': 48, 'horizontal': 'center', 'vertical': 'bottom', 'color': dark_green, 'background': 'transparent'}

Esto no queda del todo mal, con el marcador de puntos en la parte de arriba y el marcador de sets en la de abajo. Estéticamente el texto ahora es menos molesto y el juego ha ganado mucho visualmente.

Sin embargo, esto nos revela un problema y es que nuestros estilos están basados en posiciones fijas relativas a los bordes de la pantalla. No hemos preparado el TextRenderer para tener más control. Eso nos perjudicará en la siguiente tarea, en la que queremos mostrar la puntuación de todo el partido una vez acabado.

De momento, damos por terminada la tarea y hacemos commit de lo que tenemos hasta ahora.

Mostrar los sets en el marcador final.

Ya casi estamos. La última tarea nos va a requerir cambiar un poco el modo en que mostramos texto en pantalla. Para poder mostrar los parciales de varios sets, junto con el resultado final, necesitamos tener más control para definir la posición del texto. Como mínimo poder especificarlo en puntos. Tampoco nos vendría mal poder pasar varias líneas de texto que se mostrarían una debajo de la otra.

Así que quizá tengamos que cambiar un poco este TextRenderer para poder trabajar de este manera.

Nos interesaría reorganizar el tema de estilos para separar la posición del aspecto del texto. Esto debería facilitar los cambios necesarios para tener más flexibilidad. Por ejemplo, podrían indicar varias líneas para renderizar una debajo de otra, usando un mismo estilo.

En el fondo, el problema que tenemos aquí es un problema de deuda técnica. En el pasado hemos optado por una solución que, aunque nos ha servido, ahora mismo nos impide resolver rápidamente una feature. Ha llegado el momento de pagarla.

Vamos a intentar que el pago no sea muy doloroso. Aplicando el principio abierto/cerrado, crearemos un nuevo TextRenderer que nos ayude con los nuevos requisitos sin afectar a lo que ya tenemos. Usaremos un patrón similar a Strangler, envolviendo TextRenderer en una nueva clase. Inicialmente exponemos dos métodos, uno de ellos blit, mantiene la compatibilidad con el uso actual, mientras que el otro multi_blit nos permite introducir el nuevo comportamiento. Posteriormente, podremos refactorizar.

Comenzamos de esta manera:

import pygame
from pygame.font import Font
from pygame.font import get_default_font

import pong.config
from pong.utils.textrenderer import TextRenderer


class TextWriter(object):
    def __init__(self, renderer: TextRenderer):
        self.writer = renderer
        self.surface = renderer.surface

    def blit(self, line, style):
        self.writer.blit(line, style)

    def multi_blit(self, lines, position):
        for line in lines:
            the_font = Font(get_default_font(), line[1]['font_size'])
            transparent = line[1]['background'] == 'transparent'

            if transparent:
                background = pong.config.chroma
            else:
                background = line[1]['background']

            text = the_font.render(line[0], False, line[1]['color'], background)
            if transparent:
                text.set_colorkey(background)

            if isinstance(position[0], str):
                if position[0] == 'center':
                    x = self.surface.get_rect().width // 2 - text.get_rect().width // 2
            else:
                x = position[0]

            self.surface.blit(text, (x, position[1]))

            position = (position[0], position[1] + text.get_rect().height)

Y reemplazamos:

from pong.app.window import Window
from pong.utils.textrenderer import TextRenderer
from pong.utils.textwriter import TextWriter


class Scene(object):
    def __init__(self, window: Window):
        self.window = window
        self.text_renderer = TextWriter(TextRenderer(self.window.screen))

    def run(self):
        return 0

En ScoreBoard:

    def draw(self, scene):
        lines = [
            (self.points(), pong.config.style_score),
            (self.sets(), pong.config.style_sets)
        ]
        scene.text_renderer.multi_blit(lines, ('center', 40))

Como se puede ver, queremos poder especificar la posición tanto de forma relativa como absoluta. Gracias a esto, podemos implementar la forma deseada inicialmente de mostrar el marcador.

Ahora nos toca mostrar el marcador final. Después de corregir algunos problemas y cambiar nombres para ser más explícitas, ScoreBoard queda así:

import pong.config


class ScoreBoard:
    def __init__(self, score_manager):
        self.score_manager = score_manager

    def draw(self, scene):
        lines = [
            (self.points(), pong.config.style_score),
            (self.sets(), pong.config.style_sets)
        ]
        scene.text_renderer.multi_blit(lines, ('center', 40))

    def points(self):
        return "{0} : {1}".format(
            self.score_manager.points()[0],
            self.score_manager.points()[1],
        )

    def sets(self):
        return "{0} : {1}".format(
            self.score_manager.sets()[0],
            self.score_manager.sets()[1],
        )

    def end_of_game(self):
        return self.score_manager.end_of_game()

    def final_board(self, scene):
        winner = self.score_manager.winner()

        line = (
            "{0} WON".format(winner.name), pong.config.style_end_title
        )

        scene.text_renderer.multi_blit([line], ('center', 30))

        partials = self.score_manager.partials()

        lines = [(self.sets(), pong.config.style_score)]
        for set_index in range(len(partials[0])):
            line = "{0} : {1}".format(
                partials[0][set_index],
                partials[1][set_index]
            )
            lines.append((line, pong.config.style_sets))

        scene.text_renderer.multi_blit(lines, ('center', 160))

Con esto, conseguimos completar la historia y el sprint. Quedaría hacer una limpieza del código, pero es algo que podría cerrarse en el siguiente sprint. En todo caso, podemos hacer commit de lo que hemos conseguido.

August 30, 2020

Etiquetas: python   good-practices  

Temas

good-practices

refactoring

php

testing

tdd

python

blogtober19

design-principles

design-patterns

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile