Pong en Python. Eliminando deuda técnica.

por Fran Iglesias

Vamos a seguir mejorando el manejo de textos para tener más agilidad en el futuro.

Estamos dedicando bastante tiempo a un tema un poco menor, como es el renderizado de texto. Sin embargo, es algo que puede ocurrir en cualquier proyecto. En ocasiones tenemos que ir despacio, o sea detenernos en algún desarrollo que nos ayude a mejorar cómo hacemos cosas, para poder entregar más rápido valor en el futuro próximo.

Cuando el código existente nos ralentiza a la hora de entregar nuevas prestaciones porque requiere mucho trabajo, resulta difícil entender cómo funciona o manifiesta fragilidad ante los cambios, estamos ante un caso de deuda técnica.

No todo el legacy es deuda técnica

Como ya he dicho en entregas anteriores, me estoy planteando este desarrollo como un ejercicio de trabajo en proyectos legacy. Mi objetivo es que cada entrega, que en este contexto se materializa como un commit a master, mantenga como mínimo la funcionalidad existente a la vez que se mejora la calidad general del código y se añaden nuevas features. Dicho en otras palabras: los cambios no generan errores ni menoscaban el funcionamiento del juego, nos permiten avanzar en términos de robustez y mantenibilidad, y nos habilitan para añadir o mejorar la funcionalidad.

Si te has fijado en todos los artículos todavía no hemos modificado demasiadas cosas del legacy. Hemos podido embeberlo en una estructura de código más sólida, que nos ha permitido testear lo suficiente como para poder entregar con un mínimo de garantías. Hasta ahora el proceso ha sido bastante fluído. Es código poco estructurado y poco flexible, pero que proporciona la funcionalidad deseada. Es legacy, pero no podría considerarlo deuda técnica.

Sin embargo, hemos llegado a un aspecto que es aparentemente trivial como el de mostrar textos en nuestro juego. La cantidad de código que necesitamos para mostrar una línea de texto con las características deseadas es desproporcionadamente grande y la “incertidumbre” sobre el resultado también. Añadir un texto nuevo en el juego tiene un coste muy alto que hay que pagar cada vez que necesitamos hacerlo. Esto encaja con el concepto de deuda técnica.

Liquidando la deuda

Por eso, en el artículo anterior empezamos a buscar una solución. Sin embargo, todavía tenemos margen para mejorarla. El primer paso, ha sido refactorizar y ampliar la capacidad de nuestro TextRenderer y mejorar un poco la interfaz del método blit:

import pygame

import pong.config


class TextRenderer():
    def __init__(self, surface):
        self.surface = surface
        self.padding = 30

    def blit(self, text_to_render, font_size, horizontal, vertical):
        the_font = pygame.font.Font(pygame.font.get_default_font(), font_size)
        text = the_font.render(text_to_render, True, pong.config.white, pong.config.black)
        text.set_colorkey(pong.config.black)
        position = self.compute_position(text, horizontal, vertical)
        self.surface.blit(text, position)

    def compute_position(self, text, horizontal, vertical):
        x = 0
        y = 0
        if horizontal == 'left':
            x = self.padding
        if horizontal == 'center':
            x = self.surface.get_rect().width // 2 - text.get_rect().width // 2
        if horizontal == 'right':
            x = self.surface.get_rect().width - text.get_rect().width - self.padding
        if vertical == 'top':
            y = self.padding
        if vertical == 'middle':
            y = self.surface.get_rect().height // 2 - text.get_rect().height // 2
        if vertical == 'bottom':
            y = self.surface.get_rect().height - text.get_rect().height - self.padding
        return x, y

Sin embargo, todavía tenemos algunas carencias: no podemos cambiar el color del texto y hemos perdido cierta flexibilidad para usarlo. Lo que quiero conseguir es poder usarlo así:

    text_renderer.blit('Text to show', style)

Siendo style un diccionario en el que pasar todas las características del texto, no solo el tamaño como ahora, con la idea de definir los que sean necesarios en config.py, garantizando coherencia y predictibilidad en su uso.

Vamos a usar el siguiente enfoque.

Cambiamos el nombre del método blit y sus usos a old_blit. Como todavía no lo estamos usando en muchos lugares es un cambio con poco coste:

    def old_blit(self, text_to_render, font_size, horizontal, vertical):
        the_font = pygame.font.Font(pygame.font.get_default_font(), font_size)
        text = the_font.render(text_to_render, True, pong.config.white, pong.config.black)
        text.set_colorkey(pong.config.black)
        position = self.compute_position(text, horizontal, vertical)
        self.surface.blit(text, position)

Creamos un método blit que utilice la nueva signatura, y que llama al método old_blit:

    def blit(self, text_to_render, style):
        self.old_blit(text_to_render, style['font_size'], style['horizontal'], style['vertical'])

Ahora podemos definir los estilos que necesitemos en config.py:

# TEXT STYLES

style_prompt = {'font_size': 30, 'horizontal': 'center', 'vertical': 'bottom', 'color': white, 'background': 'transparent'}
style_main_title = {'font_size': 60, 'horizontal': 'center', 'vertical': 'middle', 'color': white, 'background': 'transparent'}

Por el momento estos son suficientes, ya definiremos más a medida que los necesitemos.

Ahora mismo podríamos desplegar este código sin afectar a la funcionalidad actual. Además podríamos empezar a usar el nuevo método simultáneamente con la versión antigua.

Por ejemplo, en EndScene el texto “Game Finished” debería tener color amarillo, una cosa que hemos perdido en el último cambio. Para hacerlo volver, tenemos que dar soporte a la propiedad color de style. Vamos a ello.

Vamos a necesitar un estilo nuevo, que llamaremos txt_end_title:

# TEXT STYLES

style_prompt = {'font_size': 30, 'horizontal': 'center', 'vertical': 'bottom', 'color': white, 'background': 'transparent'}
style_main_title = {'font_size': 60, 'horizontal': 'center', 'vertical': 'middle', 'color': white, 'background': 'transparent'}
style_end_title = {'font_size': 60, 'horizontal': 'center', 'vertical': 'middle', 'color': yellow, 'background': 'transparent'}

Ahora cambiamos la EndScene para usar el método nuevo, con su estilo:

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.window.screen)

        self.text_renderer.blit('Game finished', pong.config.style_end_title)
        self.text_renderer.old_blit('Press any key to exit', pong.config.text_prompt, 'center', 'bottom')

        pygame.display.flip()
        done = False
        while not done:
            for event in pygame.event.get():
                if event.type == pygame.KEYDOWN:
                    done = True

        return 0

Y nos aseguramos de que sigue funcionando. Obviamente el color del texto seguirá siendo blanco, pero esto nos garantiza una línea de base.

Mi siguiente paso será reimplementar blit, para lo cual copio el código del old_blit y lo modifico para usar las claves del diccionario, entre ellas la de ‘color’:

import pygame

import pong.config


class TextRenderer():
    def __init__(self, surface):
        self.surface = surface
        self.padding = 30

    def blit(self, text_to_render, style):
        the_font = pygame.font.Font(pygame.font.get_default_font(), style['font_size'])
        text = the_font.render(text_to_render, True, style['color'], pong.config.black)
        text.set_colorkey(pong.config.black)
        position = self.compute_position(text, style['horizontal'], style['vertical'])
        self.surface.blit(text, position)

    def old_blit(self, text_to_render, font_size, horizontal, vertical):
        the_font = pygame.font.Font(pygame.font.get_default_font(), font_size)
        text = the_font.render(text_to_render, True, pong.config.white, pong.config.black)
        text.set_colorkey(pong.config.black)
        position = self.compute_position(text, horizontal, vertical)
        self.surface.blit(text, position)

    def compute_position(self, text, horizontal, vertical):
        x = 0
        y = 0
        if horizontal == 'left':
            x = self.padding
        if horizontal == 'center':
            x = self.surface.get_rect().width // 2 - text.get_rect().width // 2
        if horizontal == 'right':
            x = self.surface.get_rect().width - text.get_rect().width - self.padding
        if vertical == 'top':
            y = self.padding
        if vertical == 'middle':
            y = self.surface.get_rect().height // 2 - text.get_rect().height // 2
        if vertical == 'bottom':
            y = self.surface.get_rect().height - text.get_rect().height - self.padding
        return x, y

Nos quedaría dar soporte al pseudo-color ‘transparent’. Como se puede ver en el código, para hacer transparente el color de fondo se debe usar text.set_colorkey(pong.config.black), lo que provoca que se use ese color como canal alfa. En el código actual se usa el negro como color de fondo. Yo voy a definir un color ‘chroma’ para tratar esos casos (por ahora es negro, pero podría cambiar en algún momento si obtenemos mejores resultados con otro color):

chroma = (0, 0, 0)

Y en este código damos soporte al fondo transparente:

    def blit(self, text_to_render, style):
        the_font = pygame.font.Font(pygame.font.get_default_font(), style['font_size'])
        transparent = style['background'] == 'transparent'

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

        text = the_font.render(text_to_render, transparent, style['color'], background)
        if transparent:
            text.set_colorkey(background)

        position = self.compute_position(text, style['horizontal'], style['vertical'])
        self.surface.blit(text, position)

Con esto estamos en condiciones de utilizar el nuevo método blit y deshacernos del viejo.

Nos queda un lugar en el que se muestran textos que es en ScoreBoard. Cuando tiene que mostrar el marcador, ScoreBoard recibe una Surface, pero podemos hacer que reciba una Scene, de este modo puede usar el TextRenderer:

import pygame

import pong.config
from pong.config import POINTS_TO_WIN


class ScoreBoard:
    def __init__(self, player1, player2):
        self.player1 = player1
        self.player2 = player2
        self.target = POINTS_TO_WIN

    def draw(self, scene):
        board = " {0} : {1} ".format(self.player1.score, self.player2.score)
        scene.text_renderer.blit(board, pong.config.style_score)

    def stop(self):
        return self.player1.score == self.target or self.player2.score == self.target

    def winner(self, scene):
        if self.player1.score > self.player2.score:
            winner = self.player1
        else:
            winner = self.player2
        board = " {0} WON! ({1}-{2}) ".format(winner.name, self.player1.score, self.player2.score)
        scene.text_renderer.blit(board, pong.config.style_score)

En cuanto a calidad de código esto todavía no es satisfactorio, pero ahora el conocimiento sobre cómo dibujar texto en el juego ya está en un solo lugar.

Revisamos que todos los tests pasen y que la aplicación funciona correctamente para hacer un nuevo commit. En esta sesión hemos conseguido atajar una buena parte de deuda técnica y mejorar nuestra capacidad de entrega en el futuro.

Lo que vendrá en las próximas sesiones es introducir nuevas features en nuestro juego que hasta ahora no estaban y que nos empujarán hacia nuevas mejoras de código.

June 7, 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