Búsqueda de sitios web

Construya un motor de juego Tic-Tac-Toe con un reproductor de IA en Python


Cuando eres niño, aprendes a jugar al tres en raya, lo que algunas personas conocen como tres en raya. El juego sigue siendo divertido y desafiante hasta que entras en la adolescencia. Luego, aprenderá a programar y descubrirá el placer de codificar una versión virtual de este juego para dos jugadores. Como adulto, es posible que aún aprecies la simplicidad del juego al usar Python para crear un oponente con inteligencia artificial (IA).

Al completar esta detallada aventura paso a paso, crearás un motor de juego extensible con un reproductor informático inmejorable que utiliza el algoritmo minimax. para jugar al tres en raya. A lo largo del camino, se sumergirá en el diseño de clases inmutables, la arquitectura genérica de complementos y las prácticas y patrones modernos del código Python.

En este tutorial, aprenderá cómo:

  • Cree una biblioteca Python reutilizable con el motor del juego tres en raya
  • Modelar el dominio del tres en raya siguiendo el estilo del código Pythonic
  • Implementar jugadores artificiales, incluido uno basado en el algoritmo minimax
  • Cree una frontal de consola basada en texto para el juego con un jugador humano
  • Explora estrategias para optimizaciones de rendimiento

Haga clic en el siguiente enlace para descargar el código fuente completo de este proyecto:

Demostración: reproductor de IA Tic-Tac-Toe en Python

Al final de este tutorial, tendrá una biblioteca Python altamente reutilizable y extensible con un motor de juego abstracto para tres en raya. Encapsulará reglas de juego universales y jugadores de computadora, incluido uno que nunca pierde debido al soporte básico de inteligencia artificial. Además, creará una front-end de consola de muestra que se basa en su biblioteca e implementa un juego de tres en raya interactivo basado en texto que se ejecuta en la terminal.

Así es como se vería el juego real entre dos jugadores:

Generalmente, puedes mezclar y elegir jugadores entre un jugador humano, un jugador simulado por computadora que realiza movimientos al azar y un jugador inteligente por computadora que sigue la estrategia óptima. También puedes especificar qué jugador debe hacer el primer movimiento, aumentando sus posibilidades de ganar o empatar.

Más adelante, podrás adaptar tu biblioteca genérica de tres en raya para diferentes plataformas, como un entorno de escritorio con ventana o un navegador web. Si bien en este tutorial solo seguirá las instrucciones sobre cómo crear una aplicación de consola, puede encontrar ejemplos de interfaz de usuario de Tkinter y PyScript en los materiales de apoyo.

La interfaz de Tkinter es una versión simplificada del mismo juego que se describe en un tutorial separado, que solo sirve como demostración de la biblioteca en un entorno de escritorio:

A diferencia del original, no parece tan ingenioso ni te permite reiniciar el juego fácilmente. Sin embargo, agrega la opción de jugar contra la computadora u otro jugador humano si así lo deseas.

La interfaz de PyScript te permite a ti o a tus amigos jugar en un navegador web incluso cuando no tienen Python instalado en su computadora, lo cual es un beneficio notable:

Si eres aventurero y sabes un poco de PyScript o JavaScript, entonces puedes ampliar esta interfaz añadiendo la posibilidad de jugar en línea con otro jugador humano a través de la red. Para facilitar la comunicación, necesitaría implementar un servidor web remoto utilizando el protocolo WebSocket, por ejemplo. Eche un vistazo a un ejemplo de servidor y cliente WebSocket en funcionamiento en otro tutorial para tener una idea de cómo podría funcionar.

Vale la pena señalar que cada una de las tres interfaces mostradas en esta sección simplemente implementa una capa de presentación diferente para la misma biblioteca de Python, que proporciona la lógica del juego y los jugadores subyacentes. No hay redundancia innecesaria ni duplicación de código entre ellos, gracias a la clara separación de preocupaciones y otros principios de programación que practicará en este tutorial.

Descripción del proyecto

El proyecto que va a construir consta de dos componentes de alto nivel que se muestran en el siguiente diagrama:

El primer componente es una biblioteca Python abstracta de tres en raya, que permanece independiente sobre las posibles formas de presentar el juego al usuario en forma gráfica. En cambio, contiene la lógica central del juego y dos jugadores artificiales. Sin embargo, la biblioteca no puede sostenerse por sí sola, por lo que también creará una interfaz de muestra para recopilar la entrada del usuario desde el teclado y visualizar el juego en la consola usando texto sin formato.

Comenzará implementando los detalles de bajo nivel de la biblioteca de tres en raya y luego los usará para implementar una interfaz de juego de nivel superior de abajo hacia arriba. Cuando termine este tutorial, la estructura de archivos completa resultante se verá así:

tic-tac-toe/
│
├── frontends/
│   │
│   └── console/
│       ├── __init__.py
│       ├── __main__.py
│       ├── args.py
│       ├── cli.py
│       ├── players.py
│       └── renderers.py
│
└── library/
    │
    ├── src/
    │   │
    │   └── tic_tac_toe/
    │       │
    │       ├── game/
    │       │   ├── __init__.py
    │       │   ├── engine.py
    │       │   ├── players.py
    │       │   └── renderers.py
    │       │
    │       ├── logic/
    │       │   ├── __init__.py
    │       │   ├── exceptions.py
    │       │   ├── minimax.py
    │       │   ├── models.py
    │       │   └── validators.py
    │       │
    │       └── __init__.py
    │
    └── pyproject.toml

La carpeta frontends/ está destinada a albergar una o más implementaciones concretas del juego, como la de tu consola basada en texto, mientras que library/ es la carpeta de inicio de la biblioteca de juegos. Puede considerar ambas carpetas de nivel superior como proyectos relacionados pero separados.

Tenga en cuenta que la interfaz de su consola contiene el archivo __main__.py, lo que lo convierte en un paquete Python ejecutable que podrá invocar desde la línea de comandos usando la opción -m de Python. . Suponiendo que cambiaste el directorio de trabajo actual a frontends/ después de descargar el código fuente completo que escribirás en este tutorial, puedes iniciar el juego con el siguiente comando:

(venv) $ python -m console

Recuerde que Python debe poder encontrar la biblioteca de tres en raya, de la que depende su interfaz, en la ruta de búsqueda del módulo. La mejor práctica para garantizar esto es crear y activar un entorno virtual compartido e instalar la biblioteca con pip. Encontrará instrucciones detalladas sobre cómo hacer esto en el archivo README de los materiales de apoyo.

La biblioteca tic-tac-toe es un paquete de Python llamado tic_tac_toe que consta de dos subpaquetes:

  1. tic_tac_toe.game: Un andamio diseñado para extenderse por los extremos
  2. tic_tac_toe.logic: Los componentes básicos del juego de tres en raya

Pronto profundizarás en cada uno de ellos. El archivo pyproject.toml contiene los metadatos necesarios para crear y empaquetar la biblioteca. Para instalar la biblioteca descargada o el código terminado que creará en este tutorial en un entorno virtual activo, pruebe este comando:

(venv) $ python -m pip install --editable library/

Durante el desarrollo, puede realizar una instalación editable usando pip con el indicador -e o --editable para montar el código fuente de la biblioteca en lugar del paquete creado en su entorno virtual. Esto evitará que tenga que repetir la instalación después de realizar cambios en la biblioteca para reflejarlos en su interfaz.

Bien, ¡eso es lo que vas a construir! Pero antes de comenzar, consulte los requisitos previos.

Requisitos previos

Este es un tutorial avanzado que aborda una amplia gama de conceptos de Python con los que debería sentirse cómodo para poder avanzar sin problemas. Utilice los siguientes recursos para familiarizarse o refrescar la memoria sobre algunos temas importantes:

  • Programación orientada a objetos (POO)
  • Herencia y composición
  • clases abstractas
  • Clases de datos
  • Escriba sugerencias
  • Expresiones regulares
  • Almacenamiento en caché
  • recursividad

El proyecto que va a crear se basa únicamente en la biblioteca estándar de Python y no tiene dependencias externas. Dicho esto, necesitará al menos Python 3.10 o posterior para aprovechar la sintaxis y las funciones más recientes que se aprovechan en este tutorial. Si actualmente utiliza una versión anterior de Python, puede instalar y administrar varias versiones de Python con pyenv o probar la última versión de Python en Docker.

Por último, debes conocer las reglas del juego que implementarás. El clásico tres en raya se juega en una cuadrícula de celdas o cuadrados de tres por tres donde cada jugador coloca su marca, una X o una O, en una celda vacía. El primer jugador que coloque tres de sus marcas en fila horizontal, vertical o diagonal gana el juego.

Paso 1: modelar el dominio del juego Tic-Tac-Toe

En este paso, identificará las partes que componen un juego de tres en raya y las implementará utilizando un enfoque orientado a objetos. Al modelar el dominio del juego con objetos inmutables, obtendrás un código modular y componible que es más fácil de probar, mantener, depurar y razonar, entre varias otras ventajas.

Para empezar, abra el editor de código de su elección, como Visual Studio Code o PyCharm, y cree un nuevo proyecto llamado tic-tac-toe, que también se convertirá en el nombre de la carpeta de su proyecto. Hoy en día, la mayoría de los editores de código te darán la opción de crear un entorno virtual para tu proyecto automáticamente, así que sigue adelante y haz lo mismo. Si el suyo no lo hace, cree el entorno virtual manualmente desde la línea de comando:

$ cd tic-tac-toe/
$ python3 -m venv venv/

Esto creará una carpeta llamada venv/ en tic-tac-toe/. No es necesario que active su nuevo entorno virtual a menos que planee continuar trabajando en la sesión de línea de comandos actual.

A continuación, diseñe esta estructura básica de archivos y carpetas en su nuevo proyecto, recordando usar guiones bajos (_) en lugar de guiones (-) para el paquete Python en el . >src/ subcarpeta:

tic-tac-toe/
│
├── frontends/
│   │
│   └── console/
│       ├── __init__.py
│       └── __main__.py
│
└── library/
    │
    ├── src/
    │   │
    │   └── tic_tac_toe/
    │       │
    │       ├── game/
    │       │   └── __init__.py
    │       │
    │       ├── logic/
    │       │   └── __init__.py
    │       │
    │       └── __init__.py
    │
    └── pyproject.toml

Todos los archivos en el árbol de archivos anterior deberían estar vacíos en este punto. Los llenará sucesivamente con contenido y agregará más archivos a medida que avance en este tutorial. Comience editando el archivo pyproject.toml ubicado al lado de su subcarpeta src/. Puedes pegar en él esta configuración de empaquetado bastante mínima para tu biblioteca de tres en raya:

# pyproject.toml

[build-system]
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "tic-tac-toe"
version = "1.0.0"

Usted especifica las herramientas de compilación requeridas, que Python descargará e instalará si es necesario, junto con algunos metadatos para su proyecto. Agregar el archivo pyproject.toml a la biblioteca le permite compilarlo e instalarlo como un paquete Python en su entorno virtual activo.

Abra la ventana de la terminal y ejecute los siguientes comandos para activar su entorno virtual si aún no lo ha hecho e instale la biblioteca de tres en raya usando el modo editable:

$ source venv/bin/activate
(venv) $ python -m pip install --editable library/

Aunque todavía no hay código Python en su biblioteca, instalarlo ahora con el indicador --editable permitirá que el intérprete de Python importe las funciones y clases que agregará en breve directamente desde su proyecto. De lo contrario, cada vez que realice un cambio en su código fuente y desee probarlo, deberá recordar compilar e instalar la biblioteca en su entorno virtual nuevamente.

Ahora que tiene una estructura general para su proyecto, puede comenzar a implementar algo de código. Al final de este paso, tendrás todas las piezas esenciales de un juego de tres en raya, incluida la lógica del juego y la validación del estado, por lo que estarás listo para combinarlas en un motor de juego abstracto.

Enumerar las marcas de los jugadores

Al comienzo del juego, a cada jugador de tres en raya se le asigna uno de dos símbolos, ya sea cruz (X) o nada (O), que utilizan para marcar ubicaciones en el tablero de juego. Dado que solo hay dos símbolos que pertenecen a un conjunto fijo de valores discretos, puede definirlos dentro de un tipo enumerado o enumeración. Es preferible usar enumeraciones que constantes debido a su seguridad de tipos mejorada, espacio de nombres común y acceso programático a sus miembros.

Cree un nuevo módulo de Python llamado models en el paquete tic_tac_toe.logic:

tic-tac-toe/
│
└── library/
    │
    ├── src/
    │   │
    │   └── tic_tac_toe/
    │       │
    │       ├── game/
    │       │   └── __init__.py
    │       │
    │       ├── logic/
    │       │   ├── __init__.py
    │       │   └── models.py
    │       │
    │       └── __init__.py
    │
    └── pyproject.toml

Utilizará este archivo durante el resto de este paso para definir objetos del modelo de dominio de tres en raya.

Ahora, importe el módulo enum de la biblioteca estándar de Python y defina un nuevo tipo de datos en sus modelos:

# tic_tac_toe/logic/models.py

import enum

class Mark(enum.Enum):
    CROSS = "X"
    NAUGHT = "O"

Las dos instancias singleton de la clase Mark, los miembros de enumeración Mark.CROSS y Mark.NAUGHT, representan los símbolos de los jugadores. De forma predeterminada, no puede comparar un miembro de una enumeración de Python con su valor. Por ejemplo, comparar Mark.CROSS == "X" dará False. Esto está diseñado para evitar confundir valores idénticos definidos en diferentes lugares y tener una semántica no relacionada.

Sin embargo, a veces puede ser más conveniente pensar en las marcas del jugador en términos de cadenas en lugar de miembros de enumeración. Para que eso suceda, defina Mark como una clase mixta de los tipos str y enum.Enum:

# tic_tac_toe/logic/models.py

import enum

class Mark(str, enum.Enum):
    CROSS = "X"
    NAUGHT = "O"

Esto se conoce como enumeración derivada, cuyos miembros se pueden comparar con instancias del tipo mixto. En este caso, ahora puede comparar Mark.NAUGHT y Mark.CROSS con valores de cadena.

import enum

class Mark(enum.StrEnum):
    CROSS = "X"
    NAUGHT = "O"

Los miembros de enum.StrEnum también son cadenas, lo que significa que puedes usarlos casi en cualquier lugar donde se espere una cadena normal.

Una vez que asignas una puntuación determinada al primer jugador, al segundo jugador se le debe asignar la única puntuación restante y no asignada. Debido a que las enumeraciones son clases glorificadas, eres libre de ponerles métodos y propiedades comunes. Por ejemplo, puede definir una propiedad de un miembro Mark que devolverá el otro miembro:

# tic_tac_toe/logic/models.py

import enum

class Mark(str, enum.Enum):
    CROSS = "X"
    NAUGHT = "O"

    @property
    def other(self) -> "Mark":
        return Mark.CROSS if self is Mark.NAUGHT else Mark.NAUGHT

El cuerpo de su propiedad es una sola línea de código que utiliza una expresión condicional para determinar la marca correcta. Las comillas alrededor del tipo de devolución en la firma de su propiedad son obligatorias para realizar una declaración anticipada y evitar un error debido a un nombre no resuelto. Después de todo, afirmas devolver una Marca, que aún no se ha definido completamente.

# tic_tac_toe/logic/models.py

from __future__ import annotations

import enum

class Mark(str, enum.Enum):
    CROSS = "X"
    NAUGHT = "O"

    @property
    def other(self) -> Mark:
        return Mark.CROSS if self is Mark.NAUGHT else Mark.NAUGHT

Agregar una importación especial __future__, que debe aparecer al principio de su archivo, permite la evaluación diferida de sugerencias de tipo. Utilizará este patrón más adelante para evitar el problema de la referencia circular al importar módulos de referencias cruzadas.

En Python 3.11, también puedes usar un tipo universal typing.Self para evitar la declaración directa en las sugerencias de tipo en primer lugar.

Para revelar algunos ejemplos prácticos del uso de la enumeración Mark, expanda la sección plegable a continuación:

Antes de continuar, asegúrese de haber hecho que la biblioteca sea accesible en la ruta de búsqueda del módulo, por ejemplo, instalándola en un entorno virtual activo, como se mostró anteriormente en la descripción general del proyecto:

>>> from tic_tac_toe.logic.models import Mark

>>> # Refer to a mark by its symbolic name literal
>>> Mark.CROSS
<Mark.CROSS: 'X'>
>>> Mark.NAUGHT
<Mark.NAUGHT: 'O'>

>>> # Refer to a mark by its symbolic name (string)
>>> Mark["CROSS"]
<Mark.CROSS: 'X'>
>>> Mark["NAUGHT"]
<Mark.NAUGHT: 'O'>

>>> # Refer to a mark by its value
>>> Mark("X")
<Mark.CROSS: 'X'>
>>> Mark("O")
<Mark.NAUGHT: 'O'>

>>> # Get the other mark
>>> Mark("X").other
<Mark.NAUGHT: 'O'>
>>> Mark("O").other
<Mark.CROSS: 'X'>

>>> # Get a mark's name
>>> Mark("X").name
'CROSS'

>>> # Get a mark's value
>>> Mark("X").value
'X'

>>> # Compare a mark to a string
>>> Mark("X") == "X"
True
>>> Mark("X") == "O"
False

>>> # Use the mark as if it was a string
>>> isinstance(Mark.CROSS, str)
True
>>> Mark.CROSS.lower()
'x'

>>> # Iterate over the available marks
>>> for mark in Mark:
...     print(mark)
...
Mark.CROSS
Mark.NAUGHT

Utilizará algunas de esas técnicas más adelante en este tutorial.

Ahora tienes una manera de representar las marcas disponibles que los jugadores dejarán en el tablero para avanzar en el juego. A continuación, implementará un tablero de juego abstracto con ubicaciones bien definidas para esas marcas.

Representar la cuadrícula de celdas

Si bien algunas personas juegan variantes del tres en raya con diferentes números de jugadores o diferentes tamaños de cuadrículas, tú te apegarás a las reglas más básicas y clásicas. Recordemos que el tablero del juego está representado por una cuadrícula de celdas de tres por tres en el clásico tres en raya. Cada celda puede estar vacía o marcada con una cruz o un cero.

Debido a que representa marcas con un solo carácter, puede implementar la cuadrícula usando una cadena de exactamente nueve caracteres correspondientes a las celdas. Una celda puede estar vacía, en cuyo caso la llenarás con el carácter de espacio (" "), o puede contener la marca del jugador. En este tutorial, almacenará la cuadrícula en orden de filas principales concatenando las filas de arriba a abajo.

Por ejemplo, con dicha representación, podrías expresar los tres juegos demostrados anteriormente con las siguientes cadenas literales:

    "XXOXO O  "
    "OXXXXOOOX"
    "OOOXXOXX "

Para visualizarlos mejor, puede preparar y ejecutar esta breve función en una sesión interactiva de intérprete de Python:

>>> def preview(cells):
...     print(cells[:3], cells[3:6], cells[6:], sep="\n")

>>> preview("XXOXO O  ")
XXO
XO
O

>>> preview("OXXXXOOOX")
OXX
XXO
OOX

>>> preview("OOOXXOXX ")
OOO
XXO
XX

La función toma una cadena de celdas como argumento y la imprime en la pantalla en forma de tres filas separadas talladas con el operador de corte de la cadena de entrada.

Si bien usar cadenas para representar la cuadrícula de celdas es bastante sencillo, se queda corto en términos de validar su forma y contenido. Aparte de eso, las cadenas simples no pueden proporcionar algunas propiedades adicionales específicas de la cuadrícula que podrían interesarle. Por estos motivos, creará un nuevo tipo de datos Grid encima de una cadena envuelta. en un atributo:

# tic_tac_toe/logic/models.py

import enum
from dataclasses import dataclass

# ...

@dataclass(frozen=True)
class Grid:
    cells: str = " " * 9

Defina Grid como una clase de datos congelados para que sus instancias sean inmutables, de modo que una vez que cree un objeto de cuadrícula, no podrá alterar sus celdas. Esto puede parecer limitante y derrochador al principio porque te verás obligado a crear muchas instancias de la clase Grid en lugar de simplemente reutilizar un objeto. Sin embargo, los beneficios de los objetos inmutables, incluida la tolerancia a fallas y la mejora de la legibilidad del código, superan con creces los costos en las computadoras modernas.

De forma predeterminada, cuando no especifica ningún valor para el atributo .cells, asumirá una cadena de exactamente nueve espacios para reflejar una cuadrícula vacía. Sin embargo, aún puedes inicializar la cuadrícula con un valor incorrecto para las celdas, lo que finalmente provocará que el programa se bloquee. Puedes evitar esto permitiendo que tus objetos existan solo si están en un estado válido. De lo contrario, no se crearán en absoluto, siguiendo los principios del modelo de dominio siempre válido y a prueba de fallas.

Las clases de datos toman el control de la inicialización del objeto, pero también le permiten ejecutar un enlace posterior a la inicialización para establecer propiedades derivadas en función de los valores de otros campos, por ejemplo. Aprovechará este mecanismo para realizar una validación de celda y potencialmente descartar cadenas no válidas antes de crear una instancia de un objeto de cuadrícula:

# tic_tac_toe/logic/models.py

import enum
import re
from dataclasses import dataclass

# ...

@dataclass(frozen=True)
class Grid:
    cells: str = " " * 9

    def __post_init__(self) -> None:
        if not re.match(r"^[\sXO]{9}$", self.cells):
            raise ValueError("Must contain 9 cells of: X, O, or space")

Su método especial .__post_init__() utiliza una expresión regular para verificar si el valor dado del atributo .cells tiene exactamente nueve caracteres y contiene solo los caracteres esperados, es decir , "X", "O" o " ". Hay otras formas de validar cadenas, pero las expresiones regulares son muy compactas y seguirán siendo coherentes con las reglas de validación futuras que agregará más adelante.

En este punto, puedes agregar algunas propiedades adicionales a tu clase Grid, lo que será útil al determinar el estado del juego:

# tic_tac_toe/logic/models.py

import enum
import re
from dataclasses import dataclass
from functools import cached_property

# ...

@dataclass(frozen=True)
class Grid:
    cells: str = " " * 9

    def __post_init__(self) -> None:
        if not re.match(r"^[\sXO]{9}$", self.cells):
            raise ValueError("Must contain 9 cells of: X, O, or space")

    @cached_property
    def x_count(self) -> int:
        return self.cells.count("X")

    @cached_property
    def o_count(self) -> int:
        return self.cells.count("O")

    @cached_property
    def empty_count(self) -> int:
        return self.cells.count(" ")

Las tres propiedades devuelven el número actual de cruces, ceros y celdas vacías, respectivamente. Debido a que su clase de datos es inmutable, su estado nunca cambiará, por lo que puede almacenar en caché los valores de propiedad calculados con la ayuda del decorador @cached_property del módulo functools. Esto garantizará que su código se ejecute como máximo una vez, sin importar cuántas veces acceda a estas propiedades, por ejemplo, durante la validación.

Para revelar algunos ejemplos prácticos del uso de la clase Grid, expanda la sección plegable a continuación:

Antes de continuar, asegúrese de haber hecho que la biblioteca sea accesible en la ruta de búsqueda del módulo, por ejemplo, instalándola en un entorno virtual activo, como se mostró anteriormente en la descripción general del proyecto:

>>> from tic_tac_toe.logic.models import Grid

>>> # Create an empty grid
>>> Grid()
Grid(cells='         ')

>>> # Create a grid of a particular cell combination
>>> Grid("XXOXO O  ")
Grid(cells='XXOXO O  ')

>>> # Don't create a grid with too few cells
>>> Grid("XO")
Traceback (most recent call last):
  ...
ValueError: Must contain 9 cells of: X, O, or space

>>> # Don't create a grid with invalid characters
>>> Grid("XXOxO O  ")
Traceback (most recent call last):
  ...
ValueError: Must contain 9 cells of: X, O, or space

>>> # Get the count of Xs, Os, and empty cells
>>> grid = Grid("OXXXXOOOX")
>>> grid.x_count
5
>>> grid.o_count
4
>>> grid.empty_count
0

Ahora ya sabes cómo utilizar la clase Grid.

Usando código Python, modelaste una cuadrícula de celdas de tres por tres, que puede contener una combinación particular de marcas de jugadores. Ahora es el momento de modelar el movimiento del jugador para que la inteligencia artificial pueda evaluar y elegir la mejor opción.

Tome una instantánea del movimiento del jugador

Un objeto que represente el movimiento del jugador en tres en raya debe responder principalmente a las dos preguntas siguientes:

  1. Marca del jugador: ¿Qué marca colocó el jugador?
  2. Ubicación de Mark: ¿Dónde fue colocado?

Sin embargo, para tener una visión completa, también es necesario conocer el estado del juego antes de realizar un movimiento. Después de todo, puede ser una decisión buena o mala, dependiendo de la situación actual. También te puede resultar conveniente tener a mano el estado resultante del juego para poder asignarle una puntuación. Al simular ese movimiento, podrás compararlo con otros posibles movimientos.

Según estas ideas, puede agregar otra clase de datos inmutables a sus modelos:

# tic_tac_toe/logic/models.py

# ...

class Mark(str, enum.Enum):
    ...

@dataclass(frozen=True)
class Grid:
    ...

@dataclass(frozen=True)
class Move:
    mark: Mark
    cell_index: int
    before_state: "GameState"
    after_state: "GameState"

Ignore las dos declaraciones directas de la clase GameState por el momento. Definirá esa clase en la siguiente sección, utilizando la sugerencia de tipo como marcador de posición temporal.

Su nueva clase es estrictamente un objeto de transferencia de datos (DTO) cuyo objetivo principal es transportar datos, ya que no proporciona ningún comportamiento a través de métodos o propiedades calculadas dinámicamente. Los objetos de la clase Move constan de la marca que identifica al jugador que realizó un movimiento, un índice numérico de base cero en la cadena de celdas y los dos estados antes y después de realizar un movimiento.

La clase Move será instanciada, poblada con valores y manipulada por la clase faltante GameState. Sin él, usted mismo no podrá crear correctamente los objetos en movimiento. ¡Es hora de arreglar eso ahora!

Determinar el estado del juego

Un juego de tres en raya puede estar en uno de varios estados, incluidos tres resultados posibles:

  1. El juego aún no ha comenzado.
  2. El juego aún continúa.
  3. El partido ha terminado empatado.
  4. El juego ha terminado con el jugador X ganando.
  5. El juego ha terminado con el jugador O ganando.

Puedes determinar el estado actual de un juego de tres en raya en función de dos parámetros:

  1. La combinación de celdas en la cuadrícula.
  2. La marca del jugador titular.

Sin saber quién empezó el juego, no podrás saber a quién le toca ahora y si el movimiento dado es válido. En última instancia, no se puede evaluar adecuadamente la situación para que la inteligencia artificial pueda tomar la decisión correcta.

Para solucionarlo, comience especificando el estado del juego como otra clase de datos inmutable que consta de la cuadrícula de celdas y la marca del jugador inicial:

# tic_tac_toe/logic/models.py

# ...

class Mark(str, enum.Enum):
    ...

@dataclass(frozen=True)
class Grid:
    ...

@dataclass(frozen=True)
class Move:
    ...

@dataclass(frozen=True)
class GameState:
    grid: Grid
    starting_mark: Mark = Mark("X")

Por convención, el jugador que marca las celdas con cruces comienza el juego, de ahí el valor predeterminado de Mark("X") para la marca del jugador inicial. Sin embargo, puede cambiarlo según sus preferencias proporcionando un valor diferente en tiempo de ejecución.

Ahora, agregue una propiedad almacenada en caché que devuelva la marca del jugador que debe realizar el siguiente movimiento:

# tic_tac_toe/logic/models.py

# ...

@dataclass(frozen=True)
class GameState:
    grid: Grid
    starting_mark: Mark = Mark("X")

    @cached_property
    def current_mark(self) -> Mark:
        if self.grid.x_count == self.grid.o_count:
            return self.starting_mark
        else:
            return self.starting_mark.other

La marca del jugador actual será la misma que la del jugador inicial cuando la cuadrícula esté vacía o cuando ambos jugadores hayan marcado el mismo número de casillas. En la práctica, sólo necesitas comprobar la última condición porque una cuadrícula en blanco implica que ambos jugadores tienen cero puntos en la cuadrícula. Para determinar la marca del otro jugador, puede aprovechar su propiedad .other en la enumeración Mark.

A continuación, agregarás algunas propiedades para evaluar el estado actual del juego. Por ejemplo, puedes saber que el juego aún no ha comenzado cuando la cuadrícula está en blanco o contiene exactamente nueve celdas vacías:

# tic_tac_toe/logic/models.py

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def current_mark(self) -> Mark:
        ...

    @cached_property
    def game_not_started(self) -> bool:
        return self.grid.empty_count == 9

Aquí es donde las propiedades de tu grilla resultan útiles. Por el contrario, puedes concluir que el juego ha terminado cuando hay un claro ganador o hay empate:

# tic_tac_toe/logic/models.py

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def current_mark(self) -> Mark:
        ...

    @cached_property
    def game_not_started(self) -> bool:
        ...

    @cached_property
    def game_over(self) -> bool:
        return self.winner is not None or self.tie

La propiedad .winner, que implementará en un momento, devolverá una instancia Mark o Ninguno, mientras que .tie La propiedad será un valor booleano. Un empate se produce cuando ningún jugador ha ganado, lo que significa que no hay ganador y todos los cuadrados están llenos, dejando cero celdas vacías:

# tic_tac_toe/logic/models.py

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def current_mark(self) -> Mark:
        ...

    @cached_property
    def game_not_started(self) -> bool:
        ...

    @cached_property
    def game_over(self) -> bool:
        ...

    @cached_property
    def tie(self) -> bool:
        return self.winner is None and self.grid.empty_count == 0

Tanto las propiedades .game_over como .tie dependen de la propiedad .winner, en la que delegan. Sin embargo, encontrar un ganador es un poco más difícil. Puede, por ejemplo, intentar hacer coincidir la cuadrícula de celdas actual con una colección predefinida de patrones ganadores con expresiones regulares:

# tic_tac_toe/logic/models.py

# ...

WINNING_PATTERNS = (
    "???......",
    "...???...",
    "......???",
    "?..?..?..",
    ".?..?..?.",
    "..?..?..?",
    "?...?...?",
    "..?.?.?..",
)

class Mark(str, enum.Enum):
    ...

class Grid:
    ...

class Move:
    ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def current_mark(self) -> Mark:
        ...

    @cached_property
    def game_not_started(self) -> bool:
        ...

    @cached_property
    def game_over(self) -> bool:
        ...

    @cached_property
    def tie(self) -> bool:
        ...

    @cached_property
    def winner(self) -> Mark | None:
        for pattern in WINNING_PATTERNS:
            for mark in Mark:
                if re.match(pattern.replace("?", mark), self.grid.cells):
                    return mark
        return None

Hay ocho patrones ganadores para cada uno de los dos jugadores, que usted define usando plantillas que se asemejan a expresiones regulares. Las plantillas contienen marcadores de posición con signos de interrogación para la marca del jugador concreto. Se repiten esas plantillas y se reemplazan los signos de interrogación con los signos de ambos jugadores para sintetizar dos expresiones regulares por patrón. Cuando las celdas coinciden con un patrón ganador, devuelves la marca correspondiente. De lo contrario, devuelve Ninguno.

Conocer al ganador es una cosa, pero es posible que también desees conocer las celdas ganadoras coincidentes para diferenciarlas visualmente. En este caso, puede agregar una propiedad similar, que utiliza una lista por comprensión para devolver una lista de índices enteros de las celdas ganadoras:

# tic_tac_toe/logic/models.py

# ...

WINNING_PATTERNS = (
    "???......",
    "...???...",
    "......???",
    "?..?..?..",
    ".?..?..?.",
    "..?..?..?",
    "?...?...?",
    "..?.?.?..",
)

class Mark(str, enum.Enum):
    ...

class Grid:
    ...

class Move:
    ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def current_mark(self) -> Mark:
        ...

    @cached_property
    def game_not_started(self) -> bool:
        ...

    @cached_property
    def game_over(self) -> bool:
        ...

    @cached_property
    def tie(self) -> bool:
        ...

    @cached_property
    def winner(self) -> Mark | None:
        ...

    @cached_property
    def winning_cells(self) -> list[int]:
        for pattern in WINNING_PATTERNS:
            for mark in Mark:
                if re.match(pattern.replace("?", mark), self.grid.cells):
                    return [
                        match.start()
                        for match in re.finditer(r"\?", pattern)
                    ]
        return []

Es posible que le preocupe tener un poco de duplicación de código entre .winner y .winnning_cells, lo que viola el principio No repetirse (DRY), pero está bien. El Zen de Python dice que la practicidad vence a la pureza y, de hecho, extraer el denominador común proporcionaría poco valor aquí y haría que el código fuera menos legible.

Tu GameState está empezando a verse bastante bien. Puede reconocer correctamente todos los estados posibles del juego, pero carece de una validación adecuada, lo que lo hace propenso a errores de tiempo de ejecución. En las próximas secciones, rectificará esto codificando y haciendo cumplir algunas reglas del tres en raya.

Introducir una capa de validación separada

Al igual que con la cuadrícula, la creación de una instancia de la clase GameState debería fallar cuando la combinación proporcionada de celdas y la marca del jugador inicial no tienen sentido. Por ejemplo, actualmente es posible crear un estado de juego no válido que no refleje una jugabilidad genuina. Puedes probarlo tú mismo.

Inicie una sesión interactiva de intérprete de Python en el entorno virtual donde instaló previamente su biblioteca y luego ejecute el siguiente código:

>>> from tic_tac_toe.logic.models import GameState, Grid
>>> GameState(Grid("XXXXXXXXX"))
GameState(grid=Grid(cells='XXXXXXXXX'), starting_mark=<Mark.CROSS: 'X'>)

Aquí, inicializas un nuevo estado del juego usando una cuadrícula que comprende una cadena sintácticamente correcta con los caracteres y la longitud correctos. Sin embargo, dicha combinación de celdas es semánticamente incorrecta porque un jugador no puede llenar toda la cuadrícula con su marca.

Debido a que validar el estado del juego es relativamente complicado, implementarlo en el modelo de dominio violaría el principio de responsabilidad única y haría que su código fuera menos legible. La validación pertenece a una capa separada en su arquitectura, por lo que debe mantener el modelo de dominio y su lógica de validación en dos módulos Python diferentes sin mezclar su código. Continúe y cree dos archivos nuevos en su proyecto:

tic-tac-toe/
│
└── library/
    │
    ├── src/
    │   │
    │   └── tic_tac_toe/
    │       │
    │       ├── game/
    │       │   └── __init__.py
    │       │
    │       ├── logic/
    │       │   ├── __init__.py
    │       │   ├── exceptions.py
    │       │   ├── models.py
    │       │   └── validators.py
    │       │
    │       └── __init__.py
    │
    └── pyproject.toml

Almacenarás varias funciones auxiliares en validators.py y algunas clases de excepción en el archivo exceptions.py para desacoplar la validación del estado del juego del modelo.

Para mejorar la consistencia del código, puede extraer la validación de cuadrícula que definió anteriormente en el método __post_init__(), moverla al módulo Python recién creado y envolverla en un nueva función:

# tic_tac_toe/logic/validators.py

import re

from tic_tac_toe.logic.models import Grid

def validate_grid(grid: Grid) -> None:
    if not re.match(r"^[\sXO]{9}$", grid.cells):
        raise ValueError("Must contain 9 cells of: X, O, or space")

Tenga en cuenta que reemplazó self.cells con grid.cells porque ahora se refiere a una instancia de grid a través del argumento de la función.

Si está utilizando PyCharm, es posible que haya comenzado a resaltar una referencia no resuelta a tic_tac_toe, que no está presente en la ruta de búsqueda de módulos y paquetes de Python. PyCharm no parece reconocer correctamente las instalaciones editables, pero puedes solucionarlo haciendo clic derecho en tu carpeta src/ y marcándola como la llamada raíz de fuentes en la vista del proyecto:

Puede tener tantas carpetas marcadas como raíces de fuentes como desee. Al hacerlo, se agregarán sus rutas absolutas a la variable de entorno PYTHONPATH administrada por PyCharm. Sin embargo, esto no afectará su entorno fuera de PyCharm, por lo que ejecutar un script a través de la terminal del sistema no se beneficiará al marcar esas carpetas. En su lugar, puede activar el entorno virtual con su biblioteca instalada para importar su código.

Después de extraer la lógica de validación de la grilla, debes actualizar la parte correspondiente en tu modelo Grid delegando la validación a una abstracción apropiada:

 # tic_tac_toe/logic/models.py

 import enum
 import re
 from dataclasses import dataclass
 from functools import cached_property

+from tic_tac_toe.logic.validators import validate_grid

 # ...

 @dataclass(frozen=True)
 class Grid:
     cells: str = " " * 9

     def __post_init__(self) -> None:
-        if not re.match(r"^[\sXO]{9}$", self.cells):
-            raise ValueError("Must contain 9 cells of: X, O, or space")
+        validate_grid(self)

     @cached_property
     def x_count(self) -> int:
         return self.cells.count("X")

     @cached_property
     def o_count(self) -> int:
         return self.cells.count("O")

     @cached_property
     def empty_count(self) -> int:
         return self.cells.count(" ")

 # ...

Importa la nueva función auxiliar y la llama en el enlace posterior a la inicialización de su cuadrícula, que ahora utiliza un vocabulario de nivel superior para comunicar su intención. Anteriormente, algunos detalles de bajo nivel, como el uso de expresiones regulares, se filtraban en su modelo y no estaba claro de inmediato qué hace el método .__post_init__().

Desafortunadamente, este cambio ahora crea el notorio problema de referencia circular entre su modelo y las capas de validación, que dependen mutuamente de los bits de cada uno. Cuando intentas importar Grid, obtendrás este error:

Traceback (most recent call last):
  ...
ImportError: cannot import name 'Grid' from partially initialized module
'tic_tac_toe.logic.models' (most likely due to a circular import)
(.../tic_tac_toe/logic/models.py)

Esto se debe a que Python lee el código fuente de arriba a abajo. Tan pronto como encuentre una declaración import, saltará al archivo importado y comenzará a leerlo. Sin embargo, en este caso, el módulo validators importado quiere importar el módulo models, que aún no se ha procesado por completo. Este es un problema muy común en Python cuando comienzas a usar sugerencias de tipo.

La única razón por la que necesita importar modelos es por una sugerencia de tipo en su función de validación. Podrías salirte con la tuya sin la declaración de importación rodeando la sugerencia de tipo entre comillas ("Grid") para hacer una declaración directa como antes. Sin embargo, esta vez seguirás un idioma diferente. Puede combinar la evaluación pospuesta de anotaciones con una constante TYPE_CHECKING especial:

 # tic_tac_toe/logic/validators.py

+from __future__ import annotations

+from typing import TYPE_CHECKING

+if TYPE_CHECKING:
+    from tic_tac_toe.logic.models import Grid

 import re

-from tic_tac_toe.logic.models import Grid

 def validate_grid(grid: Grid) -> None:
     if not re.match(r"^[\sXO]{9}$", grid.cells):
         raise ValueError("Must contain 9 cells of: X, O, or space")

Importas Grid condicionalmente. La constante TYPE_CHECKING es falsa en tiempo de ejecución, pero las herramientas de terceros, como mypy, fingirán que es verdadera cuando realicen una verificación de tipo estático para permitir que se ejecute la declaración de importación. Sin embargo, debido a que ya no importa el tipo requerido en tiempo de ejecución, ahora debe usar declaraciones directas o aprovechar from __future__ import annotations, que implícitamente convertirá las anotaciones en cadenas literales.

Con toda esta plomería en su lugar, finalmente estás listo para limitar el estado del juego para que cumpla con las reglas del tres en raya. A continuación, agregará algunas funciones de validación GameState a su nuevo módulo validators.

Descartar estados de juego incorrectos

Para rechazar estados de juego no válidos, implementarás un conocido gancho posterior a la inicialización en tu clase GameState que delega el procesamiento a otra función:

# tic_tac_toe/logic/models.py

import enum
import re
from dataclasses import dataclass
from functools import cached_property

from tic_tac_toe.logic.validators import validate_game_state, validate_grid

# ...

@dataclass(frozen=True)
class GameState:
    grid: Grid
    starting_mark: Mark = Mark("X")

    def __post_init__(self) -> None:
        validate_game_state(self)

    # ...

La función de validación, validate_game_state(), recibe una instancia del estado del juego, que a su vez contiene la cuadrícula de celdas y el jugador inicial. Vas a utilizar esta información, pero primero dividirás la validación en algunas etapas más pequeñas y más enfocadas delegando partes del estado más abajo en tu módulo validators:

# tic_tac_toe/logic/validators.py

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tic_tac_toe.logic.models import GameState, Grid

import re

def validate_grid(grid: Grid) -> None:
    ...

def validate_game_state(game_state: GameState) -> None:
    validate_number_of_marks(game_state.grid)
    validate_starting_mark(game_state.grid, game_state.starting_mark)
    validate_winner(
        game_state.grid, game_state.starting_mark, game_state.winner
    )

Tu nueva función auxiliar sirve como punto de entrada a la validación del estado del juego llamando a algunas funciones posteriores que definirás en un momento.

Para evitar crear una instancia de un estado de juego con un número incorrecto de marcas de un jugador en la cuadrícula, como el que encontraste antes, debes tener en cuenta la proporción de ceros a cruces:

# tic_tac_toe/logic/validators.py

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tic_tac_toe.logic.models import GameState, Grid

import re

from tic_tac_toe.logic.exceptions import InvalidGameState

def validate_grid(grid: Grid) -> None:
    ...

def validate_game_state(game_state: GameState) -> None:
    ...

def validate_number_of_marks(grid: Grid) -> None:
    if abs(grid.x_count - grid.o_count) > 1:
        raise InvalidGameState("Wrong number of Xs and Os")

En cualquier momento, el número de marcas dejadas por un jugador debe ser igual o mayor exactamente en uno en comparación con el número de marcas dejadas por el otro jugador. Inicialmente, no hay marcas, por lo que el número de X y O es igual a cero. Cuando el primer jugador haga un movimiento, tendrá una marca más que su oponente. Pero tan pronto como el otro jugador hace su primer movimiento, la proporción se vuelve a igualar, y así sucesivamente.

Para señalar un estado no válido, genera una excepción personalizada definida en otro módulo:

# tic_tac_toe/logic/exceptions.py

class InvalidGameState(Exception):
    """Raised when the game state is invalid."""

Es habitual que las clases vacías extiendan el tipo Exception incorporado en Python sin especificar ningún método o atributo en ellas. Estas clases existen únicamente por sus nombres, que transmiten suficiente información sobre el error ocurrido en tiempo de ejecución. Tenga en cuenta que no necesita usar la instrucción pass o el literal de puntos suspensivos (...) como marcador de posición del cuerpo de la clase si usa una cadena de documentación, que puede proporcionar información adicional. documentación.

Otra inconsistencia del estado del juego relacionada con el número de marcas dejadas en la cuadrícula tiene que ver con la marca del jugador inicial, que puede estar equivocada:

# tic_tac_toe/logic/validators.py

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tic_tac_toe.logic.models import GameState, Grid

import re

from tic_tac_toe.logic.exceptions import InvalidGameState

def validate_grid(grid: Grid) -> None:
    ...

def validate_game_state(game_state: GameState) -> None:
    ...

def validate_number_of_marks(grid: Grid) -> None:
    ...

def validate_starting_mark(grid: Grid, starting_mark: Mark) -> None:
    if grid.x_count > grid.o_count:
        if starting_mark != "X":
            raise InvalidGameState("Wrong starting mark")
    elif grid.o_count > grid.x_count:
        if starting_mark != "O":
            raise InvalidGameState("Wrong starting mark")

El jugador que dejó más marcas en la parrilla tiene garantizado ser el jugador inicial. Si no, entonces sabrás que algo debe haber salido mal. Debido a que definiste Mark como una enumeración derivada de str, puedes comparar directamente la marca del jugador inicial con una cadena literal.

Finalmente, sólo puede haber un ganador, y dependiendo de quién empezó el juego, la proporción de X y Os que quedan en la cuadrícula será diferente:

# tic_tac_toe/logic/validators.py

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tic_tac_toe.logic.models import GameState, Grid, Mark

import re

from tic_tac_toe.logic.exceptions import InvalidGameState

def validate_grid(grid: Grid) -> None:
    ...

def validate_game_state(game_state: GameState) -> None:
    ...

def validate_number_of_marks(grid: Grid) -> None:
    ...

def validate_starting_mark(grid: Grid, starting_mark: Mark) -> None:
    ...

def validate_winner(
    grid: Grid, starting_mark: Mark, winner: Mark | None
) -> None:
    if winner == "X":
        if starting_mark == "X":
            if grid.x_count <= grid.o_count:
                raise InvalidGameState("Wrong number of Xs")
        else:
            if grid.x_count != grid.o_count:
                raise InvalidGameState("Wrong number of Xs")
    elif winner == "O":
        if starting_mark == "O":
            if grid.o_count <= grid.x_count:
                raise InvalidGameState("Wrong number of Os")
        else:
            if grid.o_count != grid.x_count:
                raise InvalidGameState("Wrong number of Os")

Un jugador titular tiene ventaja, por lo que cuando gane habrá dejado más marcas que su oponente. Por el contrario, el segundo jugador está en desventaja, por lo que sólo puede ganar el juego realizando el mismo número de movimientos que el jugador inicial.

Ya casi has terminado de encapsular las reglas del juego de tres en raya en código Python, pero todavía falta una pieza más importante. En la siguiente sección, escribirás código para producir sistemáticamente nuevos estados del juego simulando los movimientos de los jugadores.

Simule movimientos produciendo nuevos estados de juego

La última propiedad que agregarás a tu clase GameState es una lista fija de posibles movimientos, que puedes encontrar llenando las celdas vacías restantes en la cuadrícula con la marca del jugador actual:

# tic_tac_toe/logic/models.py

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def possible_moves(self) -> list[Move]:
        moves = []
        if not self.game_over:
            for match in re.finditer(r"\s", self.grid.cells):
                moves.append(self.make_move_to(match.start()))
        return moves

Si el juego termina, devolverás una lista vacía de movimientos. De lo contrario, identifica las ubicaciones de las celdas vacías usando una expresión regular y luego se mueve a cada una de esas celdas. Hacer un movimiento crea un nuevo objeto Move, que agregas a la lista sin cambiar el estado del juego.

Así es como se construye un objeto Move:

# tic_tac_toe/logic/models.py

import enum
import re
from dataclasses import dataclass
from functools import cached_property

from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.validators import validate_game_state, validate_grid

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    def make_move_to(self, index: int) -> Move:
        if self.grid.cells[index] != " ":
            raise InvalidMove("Cell is not empty")
        return Move(
            mark=self.current_mark,
            cell_index=index,
            before_state=self,
            after_state=GameState(
                Grid(
                    self.grid.cells[:index]
                    + self.current_mark
                    + self.grid.cells[index + 1:]
                ),
                self.starting_mark,
            ),
        )

No se permite un movimiento si la celda objetivo ya está ocupada por tu marca o la de tu oponente, en cuyo caso generas una excepción InvalidMove. Por otro lado, si la celda está vacía, entonces tomas una instantánea de la marca del jugador actual, el índice de la celda objetivo y el estado actual del juego mientras sintetizas el siguiente estado.

No olvide definir el nuevo tipo de excepción que importó:

# tic_tac_toe/logic/exceptions.py

class InvalidGameState(Exception):
    """Raised when the game state is invalid."""

class InvalidMove(Exception):
    """Raised when the move is invalid."""

¡Eso es todo! Acabas de conseguir un modelo de dominio bastante sólido del juego de tres en raya, que puedes utilizar para crear juegos interactivos para varias interfaces. El modelo resume las reglas del juego y refuerza sus restricciones.

Antes de continuar, asegúrese de haber hecho que la biblioteca sea accesible en la ruta de búsqueda del módulo, por ejemplo, instalándola en un entorno virtual activo, como se mostró anteriormente en la descripción general del proyecto:

>>> from tic_tac_toe.logic.models import GameState, Grid, Mark

>>> game_state = GameState(Grid())
>>> game_state.game_not_started
True
>>> game_state.game_over
False
>>> game_state.tie
False
>>> game_state.winner is None
True
>>> game_state.winning_cells
[]

>>> game_state = GameState(Grid("XOXOXOXXO"), starting_mark=Mark("X"))
>>> game_state.starting_mark
<Mark.CROSS: 'X'>
>>> game_state.current_mark
<Mark.NAUGHT: 'O'>
>>> game_state.winner
<Mark.CROSS: 'X'>
>>> game_state.winning_cells
[2, 4, 6]

>>> game_state = GameState(Grid("XXOXOX  O"))
>>> game_state.possible_moves
[
    Move(
        mark=<Mark.NAUGHT: 'O'>,
        cell_index=6,
        before_state=GameState(...),
        after_state=GameState(...)
    ),
    Move(
        mark=<Mark.NAUGHT: 'O'>,
        cell_index=7,
        before_state=GameState(...),
        after_state=GameState(...)
    )
]

Ahora ya sabes cómo funcionan los distintos atributos GameState y cómo combinarlos con otros objetos del modelo de dominio.

En la siguiente sección, crearás un motor de juego abstracto y tu primer jugador artificial.

Paso 2: Armado de un motor de juego Tic-Tac-Toe genérico

En este punto, debería tener todos los modelos de dominio definidos para su biblioteca de tres en raya. Ahora es el momento de crear un motor de juego que aproveche estas clases modelo para facilitar el juego de tres en raya.

Continúe y cree tres módulos Python más dentro del paquete tic_tac_toe.game ahora:

tic-tac-toe/
│
└── library/
    │
    ├── src/
    │   │
    │   └── tic_tac_toe/
    │       │
    │       ├── game/
    │       │   ├── __init__.py
    │       │   ├── engine.py
    │       │   ├── players.py
    │       │   └── renderers.py
    │       │
    │       ├── logic/
    │       │   ├── __init__.py
    │       │   ├── exceptions.py
    │       │   ├── models.py
    │       │   └── validators.py
    │       │
    │       └── __init__.py
    │
    └── pyproject.toml

El módulo engine es la pieza central del juego virtual, donde implementarás el bucle principal del juego. Definirás las interfaces abstractas que utiliza el motor del juego, junto con un reproductor de computadora de muestra, en los módulos players y renderers. Al final de este paso, estará listo para escribir una interfaz tangible para la biblioteca de tres en raya.

Realice los movimientos de los jugadores para impulsar el juego

Como mínimo, para jugar al tres en raya, necesitas tener dos jugadores, algo a lo que recurrir y un conjunto de reglas a seguir. Afortunadamente, puedes expresar estos elementos como clases de datos inmutables, que aprovechan el modelo de dominio existente de tu biblioteca. Primero, creará la clase TicTacToe en el módulo engine:

# tic_tac_toe/game/engine.py

from dataclasses import dataclass

from tic_tac_toe.game.players import Player
from tic_tac_toe.game.renderers import Renderer

@dataclass(frozen=True)
class TicTacToe:
    player1: Player
    player2: Player
    renderer: Renderer

Tanto Player como Renderer se implementarán en las siguientes secciones como clases base abstractas de Python, que solo describen la interfaz de alto nivel para su motor de juego. Sin embargo, eventualmente serán reemplazadas por clases concretas, algunas de las cuales pueden provenir de una interfaz definida externamente. El jugador sabrá qué movimiento hacer y el renderizador será responsable de visualizar la cuadrícula.

Para jugar, debes decidir qué jugador debe hacer el primer movimiento, o puedes asumir el predeterminado, que es el jugador con cruces. También debes comenzar con una cuadrícula en blanco de celdas y un estado inicial del juego:

# tic_tac_toe/game/engine.py

from dataclasses import dataclass

from tic_tac_toe.game.players import Player
from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Grid, Mark

@dataclass(frozen=True)
class TicTacToe:
    player1: Player
    player2: Player
    renderer: Renderer

    def play(self, starting_mark: Mark = Mark("X")) -> None:
        game_state = GameState(Grid(), starting_mark)
        while True:
            self.renderer.render(game_state)
            if game_state.game_over:
                break
            player = self.get_current_player(game_state)
            try:
                game_state = player.make_move(game_state)
            except InvalidMove:
                pass

El motor solicita que el renderizador actualice la vista y luego utiliza una estrategia de extracción para avanzar en el juego pidiendo a ambos jugadores que realicen sus movimientos en rondas alternas. Estos pasos se repiten en un bucle infinito hasta que termina el juego.

GameState solo conoce la marca del jugador actual, que puede ser X u O, pero no conoce los objetos de jugador específicos a los que se les asignaron esas marcas. Por lo tanto, necesita asignar la marca actual a un objeto de jugador usando este método auxiliar:

# tic_tac_toe/game/engine.py

from dataclasses import dataclass

from tic_tac_toe.game.players import Player
from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Grid, Mark

@dataclass(frozen=True)
class TicTacToe:
    player1: Player
    player2: Player
    renderer: Renderer

    def play(self, starting_mark: Mark = Mark("X")) -> None:
        game_state = GameState(Grid(), starting_mark)
        while True:
            self.renderer.render(game_state)
            if game_state.game_over:
                break
            player = self.get_current_player(game_state)
            try:
                game_state = player.make_move(game_state)
            except InvalidMove:
                pass

    def get_current_player(self, game_state: GameState) -> Player:
        if game_state.current_mark is self.player1.mark:
            return self.player1
        else:
            return self.player2

Aquí, compara los miembros de la enumeración por sus identidades utilizando el operador is de Python. Si la marca del jugador actual determinada por el estado del juego es la misma que la marca asignada al primer jugador, entonces ese es el jugador que debería hacer el siguiente movimiento.

Ambos reproductores suministrados al objeto TicTacToe deben tener marcas opuestas. De lo contrario, no podrás jugar sin violar sus reglas. Por lo tanto, es razonable validar las marcas de los jugadores al crear una instancia de la clase TicTacToe:

# tic_tac_toe/game/engine.py

from dataclasses import dataclass

from tic_tac_toe.game.players import Player
from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Grid, Mark
from tic_tac_toe.logic.validators import validate_players

@dataclass(frozen=True)
class TicTacToe:
    player1: Player
    player2: Player
    renderer: Renderer

    def __post_init__(self):
        validate_players(self.player1, self.player2)

    def play(self, starting_mark: Mark = Mark("X")) -> None:
        game_state = GameState(Grid(), starting_mark)
        while True:
            self.renderer.render(game_state)
            if game_state.game_over:
                break
            player = self.get_current_player(game_state)
            try:
                game_state = player.make_move(game_state)
            except InvalidMove:
                pass

    def get_current_player(self, game_state: GameState) -> Player:
        if game_state.current_mark is self.player1.mark:
            return self.player1
        else:
            return self.player2

Agrega un gancho posterior a la inicialización a su clase de datos y llama a otra función de validación que debe agregar en su módulo validators:

# tic_tac_toe/logic/validators.py

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tic_tac_toe.game.players import Player
    from tic_tac_toe.logic.models import GameState, Grid, Mark

import re

from tic_tac_toe.logic.exceptions import InvalidGameState

def validate_grid(grid: Grid) -> None:
    ...

def validate_game_state(game_state: GameState) -> None:
    ...

def validate_number_of_marks(grid: Grid) -> None:
    ...

def validate_starting_mark(grid: Grid, starting_mark: Mark) -> None:
    ...

def validate_winner(
    grid: Grid, starting_mark: Mark, winner: Mark | None
) -> None:
    ...

def validate_players(player1: Player, player2: Player) -> None:
    if player1.mark is player2.mark:
        raise ValueError("Players must use different marks")

Usas la comparación de identidad nuevamente para verificar las marcas de ambos jugadores y evitar que el juego comience cuando ambos jugadores usan la misma marca.

Hay una cosa más que puede salir mal. Debido a que depende de los jugadores, incluidos los jugadores humanos, decidir qué movimiento hacen, su elección podría no ser válida. Actualmente, tu clase TicTacToe detecta la excepción InvalidMove pero no hace nada útil con ella más que ignorar dicho movimiento y pedirle al jugador que haga una elección diferente. Probablemente sería útil dejar que el front-end maneje los errores, por ejemplo, mostrando un mensaje adecuado:

# tic_tac_toe/game/engine.py

from dataclasses import dataclass
from typing import Callable, TypeAlias

from tic_tac_toe.game.players import Player
from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Grid, Mark
from tic_tac_toe.logic.validators import validate_players

ErrorHandler: TypeAlias = Callable[[Exception], None]

@dataclass(frozen=True)
class TicTacToe:
    player1: Player
    player2: Player
    renderer: Renderer
    error_handler: ErrorHandler | None = None

    def __post_init__(self):
        validate_players(self.player1, self.player2)

    def play(self, starting_mark: Mark = Mark("X")) -> None:
        game_state = GameState(Grid(), starting_mark)
        while True:
            self.renderer.render(game_state)
            if game_state.game_over:
                break
            player = self.get_current_player(game_state)
            try:
                game_state = player.make_move(game_state)
            except InvalidMove as ex:
                if self.error_handler:
                    self.error_handler(ex)

    def get_current_player(self, game_state: GameState) -> Player:
        if game_state.current_mark is self.player1.mark:
            return self.player1
        else:
            return self.player2

Para permitir que el front-end decida cómo encargarse de un movimiento no válido, expone un gancho en su clase introduciendo una devolución de llamada opcional .error_handler, que recibirá la excepción. El tipo de devolución de llamada se define utilizando un alias de tipo, lo que hace que su declaración de tipo sea más concisa. El juego TicTacToe activará esta devolución de llamada en caso de un movimiento no válido, siempre que proporciones el controlador de errores.

Habiendo implementado un motor de juego abstracto de tres en raya, puedes proceder a codificar un jugador artificial. Definirás una interfaz de jugador genérica y la implementarás con un jugador de computadora de muestra que realiza movimientos al azar.

Deje que la computadora elija un movimiento aleatorio

Primero, defina un Player abstracto, que será la clase base para que los jugadores concretos se extiendan:

# tic_tac_toe/game/players.py

import abc

from tic_tac_toe.logic.models import Mark

class Player(metaclass=abc.ABCMeta):
    def __init__(self, mark: Mark) -> None:
        self.mark = mark

Una clase abstracta es aquella de la que no se puede crear una instancia porque sus objetos no se sostienen por sí solos. Su único propósito es proporcionar el esqueleto para subclases concretas. Puede marcar una clase como abstracta en Python estableciendo su metaclase en abc.ABCMeta o extendiendo el ancestro abc.ABC.

Al jugador se le asigna una instancia Mark que usará durante el juego. El jugador también expone un método público para realizar un movimiento, dado un determinado estado del juego:

# tic_tac_toe/game/players.py

import abc

from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Mark, Move

class Player(metaclass=abc.ABCMeta):
    def __init__(self, mark: Mark) -> None:
        self.mark = mark

    def make_move(self, game_state: GameState) -> GameState:
        if self.mark is game_state.current_mark:
            if move := self.get_move(game_state):
                return move.after_state
            raise InvalidMove("No more possible moves")
        else:
            raise InvalidMove("It's the other player's turn")

    @abc.abstractmethod
    def get_move(self, game_state: GameState) -> Move | None:
        """Return the current player's move in the given game state."""

Observe cómo el método público .make_move() define un algoritmo universal para realizar un movimiento, pero el paso individual para realizar el movimiento se delega a un método abstracto, que debe implementar en subclases concretas. Este diseño se conoce como patrón de método de plantilla en programación orientada a objetos.

Hacer un movimiento implica comprobar si es el turno del jugador en cuestión y si el movimiento existe. El método .get_move() devuelve None para indicar que no son posibles más movimientos, y la clase abstracta Player utiliza el operador Walrus (>=) para simplificar el código de llamada.

Para que el juego parezca más natural, puedes introducir un breve retraso para que el jugador de la computadora espere antes de elegir su movimiento. De lo contrario, la computadora haría sus movimientos instantáneamente, a diferencia de un jugador humano. Puede definir otra clase base abstracta, un poco más específica, para representar a los reproductores de computadora:

# tic_tac_toe/game/players.py

import abc
import time

from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Mark, Move

class Player(metaclass=abc.ABCMeta):
    ...

class ComputerPlayer(Player, metaclass=abc.ABCMeta):
    def __init__(self, mark: Mark, delay_seconds: float = 0.25) -> None:
        super().__init__(mark)
        self.delay_seconds = delay_seconds

    def get_move(self, game_state: GameState) -> Move | None:
        time.sleep(self.delay_seconds)
        return self.get_computer_move(game_state)

    @abc.abstractmethod
    def get_computer_move(self, game_state: GameState) -> Move | None:
        """Return the computer's move in the given game state."""

ComputerPlayer extiende Player agregando un miembro adicional, .delay_segundos, a sus instancias, que de forma predeterminada equivale a 250 milisegundos. También implementa el método .get_move() para simular un cierto tiempo de espera y luego llama a otro método abstracto específico para los reproductores de computadora.

Tener un tipo de datos de reproductor de computadora abstracto impone una interfaz uniforme, que puede satisfacer convenientemente con unas pocas líneas de código. Por ejemplo, puedes implementar que un jugador de la computadora elija movimientos al azar de la siguiente manera:

# tic_tac_toe/game/players.py

import abc
import random
import time

from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Mark, Move

class Player(metaclass=abc.ABCMeta):
    ...

class ComputerPlayer(Player, metaclass=abc.ABCMeta):
    ...

class RandomComputerPlayer(ComputerPlayer):
    def get_computer_move(self, game_state: GameState) -> Move | None:
        try:
            return random.choice(game_state.possible_moves)
        except IndexError:
            return None

Usas choice() para elegir un elemento aleatorio de una lista de posibles movimientos. Si no hay más movimientos en el estado del juego dado, obtendrás un IndexError debido a una lista vacía, así que lo detectas y devuelves None en su lugar.

Ahora tienes dos clases base abstractas, Player y ComputerPlayer, así como un RandomComputerPlayer concreto, que podrás usar en tu juegos. El único elemento restante de la ecuación antes de que puedas poner esas clases en acción es el renderizador abstracto, que definirás a continuación.

Haga un renderizador de cuadrícula abstracto de tres en raya

Darle una forma visual a la cuadrícula de tres en raya depende completamente del front-end, por lo que solo definirá una interfaz abstracta en su biblioteca:

# tic_tac_toe/game/renderers.py

import abc

from tic_tac_toe.logic.models import GameState

class Renderer(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def render(self, game_state: GameState) -> None:
        """Render the current game state."""

Esto podría haberse implementado como una función normal porque el renderizador expone solo una operación mientras obtiene el estado completo a través de un argumento. Sin embargo, es posible que subclases concretas necesiten mantener un estado adicional, como la ventana de la aplicación, por lo que tener una clase puede resultar útil en algún momento.

Bien, tienes la biblioteca de tres en raya con un modelo de dominio robusto, un motor que encapsula las reglas del juego, un mecanismo para simular movimientos e incluso un jugador de computadora concreto. En la siguiente sección, combinarás todas las piezas y crearás una interfaz de juego, ¡lo que te permitirá finalmente ver algo de acción!

Paso 3: crea una interfaz de juego para la consola

Hasta ahora, has estado trabajando en una biblioteca abstracta de un motor de juego de tres en raya, que proporciona los componentes básicos del juego. En esta sección, le dará vida codificando un proyecto independiente que se basa en esta biblioteca. Será un juego básico que se ejecutará en la consola basada en texto.

Representar la cuadrícula con códigos de escape ANSI

El aspecto más importante de cualquier interfaz de juego es proporcionar información visual a los jugadores a través de una interfaz gráfica. Debido a que en este ejemplo está limitado a la consola basada en texto, aprovechará los códigos de escape ANSI para controlar cosas como el formato o la ubicación del texto.

Cree el módulo renderers en la interfaz de su consola y defina una clase concreta que extienda el renderizador abstracto del tres en raya en él:

# frontends/console/renderers.py

from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.models import GameState

class ConsoleRenderer(Renderer):
    def render(self, game_state: GameState) -> None:
        clear_screen()

En caso de que esté utilizando Visual Studio Code y no resuelva las importaciones, intente cerrar y volver a abrir el editor. La clase ConsoleRenderer anula .render(), el único método abstracto responsable de visualizar el estado actual del juego. En este caso, comienza limpiando el contenido de la pantalla usando una función auxiliar, que puede definir debajo de la clase:

# frontends/console/renderers.py

from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.models import GameState

class ConsoleRenderer(Renderer):
    def render(self, game_state: GameState) -> None:
        clear_screen()

def clear_screen() -> None:
    print("\033c", end="")

El literal de cadena "\033" representa un carácter Esc no imprimible, que inicia una secuencia de código especial. La letra c que sigue codifica el comando para borrar la pantalla. Tenga en cuenta que la función print() finaliza automáticamente el texto con un carácter de nueva línea. Para evitar agregar una línea en blanco innecesaria, debe deshabilitarla estableciendo el argumento end.

Cuando haya un ganador, querrás distinguir sus marcas ganadoras con un texto parpadeante. Puede definir otra función auxiliar para codificar texto parpadeante utilizando el código de escape ANSI correspondiente:

# frontends/console/renderers.py

from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.models import GameState

class ConsoleRenderer(Renderer):
    def render(self, game_state: GameState) -> None:
        clear_screen()

def clear_screen() -> None:
    print("\033c", end="")

def blink(text: str) -> str:
    return f"\033[5m{text}\033[0m"

Aquí, envuelve el texto proporcionado con códigos de escape ANSI de apertura y cierre en la cadena f de Python.

Para representar la cuadrícula de tres en raya llena con las marcas de los jugadores, formateará una cadena de plantilla de varias líneas y usará el módulo textwrap para eliminar la sangría:

# frontends/console/renderers.py

import textwrap
from typing import Iterable

from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.models import GameState

class ConsoleRenderer(Renderer):
    def render(self, game_state: GameState) -> None:
        clear_screen()
        print_solid(game_state.grid.cells)

def clear_screen() -> None:
    print("\033c", end="")

def blink(text: str) -> str:
    return f"\033[5m{text}\033[0m"

def print_solid(cells: Iterable[str]) -> None:
    print(
        textwrap.dedent(
            """\
             A   B   C
           ------------
        1 ┆  {0} │ {1} │ {2}
          ┆ ───┼───┼───
        2 ┆  {3} │ {4} │ {5}
          ┆ ───┼───┼───
        3 ┆  {6} │ {7} │ {8}
    """
        ).format(*cells)
    )

La función print_solid() toma una secuencia de celdas y las imprime con un margen adicional alrededor de la esquina superior izquierda. Contiene filas y columnas numeradas indexadas por letras. Por ejemplo, una cuadrícula de tres en raya parcialmente llena puede verse así en la pantalla:

     A   B   C
   ------------
1 ┆  X │ O │ X
  ┆ ───┼───┼───
2 ┆  O │ O │
  ┆ ───┼───┼───
3 ┆    │ X │

El canal facilitará que el jugador especifique las coordenadas de la celda objetivo donde quiere poner su marca.

Si hay un ganador, querrás hacer parpadear algunas de sus celdas e imprimir un mensaje que indique quién ganó el juego. De lo contrario, imprimirás una cuadrícula sólida de celdas y, opcionalmente, informarás a los jugadores que no hay ganadores en caso de empate:

# frontends/console/renderers.py

import textwrap
from typing import Iterable

from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.models import GameState

class ConsoleRenderer(Renderer):
    def render(self, game_state: GameState) -> None:
        clear_screen()
        if game_state.winner:
            print_blinking(game_state.grid.cells, game_state.winning_cells)
            print(f"{game_state.winner} wins \N{party popper}")
        else:
            print_solid(game_state.grid.cells)
            if game_state.tie:
                print("No one wins this time \N{neutral face}")

def clear_screen() -> None:
    print("\033c", end="")

def blink(text: str) -> str:
    return f"\033[5m{text}\033[0m"

def print_solid(cells: Iterable[str]) -> None:
    print(
        textwrap.dedent(
            """\
             A   B   C
           ------------
        1 ┆  {0} │ {1} │ {2}
          ┆ ───┼───┼───
        2 ┆  {3} │ {4} │ {5}
          ┆ ───┼───┼───
        3 ┆  {6} │ {7} │ {8}
    """
        ).format(*cells)
    )

Sus mensajes contienen una sintaxis especial para alias de nombres de caracteres Unicode, incluidos emojis, para que el resultado se vea más colorido y emocionante. Por ejemplo, "\N{party popper}" representará el emoji 🎉. Tenga en cuenta que llama a otra función auxiliar, print_blinking(), que debe definir ahora:

# frontends/console/renderers.py

import textwrap
from typing import Iterable

from tic_tac_toe.game.renderers import Renderer
from tic_tac_toe.logic.models import GameState

class ConsoleRenderer(Renderer):
    def render(self, game_state: GameState) -> None:
        clear_screen()
        if game_state.winner:
            print_blinking(game_state.grid.cells, game_state.winning_cells)
            print(f"{game_state.winner} wins \N{party popper}")
        else:
            print_solid(game_state.grid.cells)
            if game_state.tie:
                print("No one wins this time \N{neutral face}")

def clear_screen() -> None:
    print("\033c", end="")

def blink(text: str) -> str:
    return f"\033[5m{text}\033[0m"

def print_blinking(cells: Iterable[str], positions: Iterable[int]) -> None:
    mutable_cells = list(cells)
    for position in positions:
        mutable_cells[position] = blink(mutable_cells[position])
    print_solid(mutable_cells)

def print_solid(cells: Iterable[str]) -> None:
    print(
        textwrap.dedent(
            """\
             A   B   C
           ------------
        1 ┆  {0} │ {1} │ {2}
          ┆ ───┼───┼───
        2 ┆  {3} │ {4} │ {5}
          ┆ ───┼───┼───
        3 ┆  {6} │ {7} │ {8}
    """
        ).format(*cells)
    )

Esta nueva función toma la secuencia de celdas y las posiciones numéricas de aquellas que deben representarse mediante texto parpadeante. Luego, hace una copia mutable de las celdas, sobrescribe las celdas especificadas con códigos de escape ANSI parpadeantes y delega la representación a print_solid().

En este punto, puedes probar tu renderizador personalizado usando dos reproductores de computadora integrados en la biblioteca de tres en raya. Guarde el siguiente código en un archivo llamado play.py ubicado en la carpeta frontends/:

# frontends/play.py

from tic_tac_toe.game.engine import TicTacToe
from tic_tac_toe.game.players import RandomComputerPlayer
from tic_tac_toe.logic.models import Mark

from console.renderers import ConsoleRenderer

player1 = RandomComputerPlayer(Mark("X"))
player2 = RandomComputerPlayer(Mark("O"))

TicTacToe(player1, player2, ConsoleRenderer()).play()

Cuando ejecutas este script, verás dos jugadores artificiales haciendo movimientos aleatorios, lo que lleva a resultados diferentes cada vez:

Si bien es interesante observar su modo de juego, no hay interactividad alguna. Ahora vas a cambiar eso dejando que los jugadores humanos decidan qué movimientos hacer.

Crear un reproductor de consola interactivo

Al final de esta sección, podrás jugar una partida de tres en raya entre un jugador humano y un jugador de computadora o dos jugadores humanos, además de los dos jugadores de computadora acabas de ver. Un jugador humano utilizará la interfaz del teclado para especificar sus movimientos.

Puede definir una nueva clase de reproductor concreta en la interfaz de su consola, que implementará el método abstracto .get_move() especificado en la biblioteca. Cree el módulo players del front-end y rellénelo con el siguiente contenido:

# frontends/console/players.py

from tic_tac_toe.game.players import Player
from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Move

class ConsolePlayer(Player):
    def get_move(self, game_state: GameState) -> Move | None:
        while not game_state.game_over:
            try:
                index = grid_to_index(input(f"{self.mark}'s move: ").strip())
            except ValueError:
                print("Please provide coordinates in the form of A1 or 1A")
            else:
                try:
                    return game_state.make_move_to(index)
                except InvalidMove:
                    print("That cell is already occupied.")
        return None

Si el juego ha terminado, devuelve Ninguno para indicar que no fue posible ningún movimiento. De lo contrario, seguirás pidiéndole al jugador un movimiento válido hasta que te proporcione uno y haga ese movimiento. Debido a que el jugador humano escribe coordenadas de celda como A1 o C3, debes convertir dicho texto en un índice numérico con la ayuda de grid_to_index(). función:

# frontends/console/players.py

import re

from tic_tac_toe.game.players import Player
from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.models import GameState, Move

class ConsolePlayer(Player):
    def get_move(self, game_state: GameState) -> Move | None:
        while not game_state.game_over:
            try:
                index = grid_to_index(input(f"{self.mark}'s move: ").strip())
            except ValueError:
                print("Please provide coordinates in the form of A1 or 1A")
            else:
                try:
                    return game_state.make_move_to(index)
                except InvalidMove:
                    print("That cell is already occupied.")
        return None

def grid_to_index(grid: str) -> int:
    if re.match(r"[abcABC][123]", grid):
        col, row = grid
    elif re.match(r"[123][abcABC]", grid):
        row, col = grid
    else:
        raise ValueError("Invalid grid coordinates")
    return 3 * (int(row) - 1) + (ord(col.upper()) - ord("A"))

La función utiliza expresiones regulares para extraer la fila y columna numéricas para que pueda calcular el índice correspondiente en la secuencia plana de celdas.

Ahora puede modificar su script de prueba importando y creando una instancia de ConsolePlayer:

# frontends/play.py

from tic_tac_toe.game.engine import TicTacToe
from tic_tac_toe.game.players import RandomComputerPlayer
from tic_tac_toe.logic.models import Mark

from console.players import ConsolePlayer
from console.renderers import ConsoleRenderer

player1 = ConsolePlayer(Mark("X"))
player2 = RandomComputerPlayer(Mark("O"))

TicTacToe(player1, player2, ConsoleRenderer()).play()

Ejecutar este script te permitirá jugar como X contra la computadora. Desafortunadamente, no existe una forma conveniente de cambiar los jugadores o indicar quién debe iniciar el juego, porque esta información está incluida en el código. A continuación, agregará una interfaz de línea de comandos para solucionarlo.

Agregar una interfaz de línea de comandos (CLI)

Ya casi has terminado de construir tu interfaz de tres en raya. Sin embargo, es hora de agregar los toques finales y convertirlo en un juego jugable implementando una útil interfaz de línea de comandos usando el módulo argparse. De esa manera, podrás elegir los tipos de jugadores y la marca de inicio antes de ejecutar el juego.

El punto de entrada a la interfaz de su consola es el módulo especial __main__.py, que hace que el paquete contenedor se pueda ejecutar mediante el comando python. Debido a que es habitual ponerle un código contenedor mínimo, mantendrá el módulo liviano delegando el procesamiento a una función importada de otro módulo:

# frontends/console/__main__.py

from .cli import main

main()

Esto hace que el código definido en cli.py sea más reutilizable en muchos lugares y más fácil de probar de forma aislada. Así es como podría verse ese código:

# frontends/console/cli.py

from tic_tac_toe.game.engine import TicTacToe

from .args import parse_args
from .renderers import ConsoleRenderer

def main() -> None:
    player1, player2, starting_mark = parse_args()
    TicTacToe(player1, player2, ConsoleRenderer()).play(starting_mark)

Importas el motor del juego, el renderizador de tu nueva consola y una función auxiliar, parse_argse(), que podrá leer argumentos de línea de comandos y, en función de ellos, devolver dos objetos de jugador y el inicio. marca del jugador.

Para implementar el análisis de argumentos, puede comenzar definiendo los tipos de reproductores disponibles como un diccionario de Python, que asocia nombres cotidianos como human con clases concretas que extienden el Player abstracto:

# frontends/console/args.py

from tic_tac_toe.game.players import RandomComputerPlayer

from .players import ConsolePlayer

PLAYER_CLASSES = {
    "human": ConsolePlayer,
    "random": RandomComputerPlayer,
}

Esto hará que sea más sencillo agregar más tipos de jugadores en el futuro. A continuación, puede escribir una función que utilice el módulo argparse para obtener los argumentos esperados desde la línea de comando:

# frontends/console/args.py

import argparse

from tic_tac_toe.game.players import Player, RandomComputerPlayer
from tic_tac_toe.logic.models import Mark

from .players import ConsolePlayer

PLAYER_CLASSES = {
    "human": ConsolePlayer,
    "random": RandomComputerPlayer,
}

def parse_args() -> tuple[Player, Player, Mark]:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-X",
        dest="player_x",
        choices=PLAYER_CLASSES.keys(),
        default="human",
    )
    parser.add_argument(
        "-O",
        dest="player_o",
        choices=PLAYER_CLASSES.keys(),
        default="random",
    )
    parser.add_argument(
        "--starting",
        dest="starting_mark",
        choices=Mark,
        type=Mark,
        default="X",
    )
    args = parser.parse_args()

El código anterior se traduce en los siguientes tres argumentos opcionales, todos los cuales tienen valores predeterminados:

Argument Default Value Description
-X human Assigns X to the specified player
-O random Assigns O to the specified player
--starting X Determines the starting player’s mark

En este punto, la función analiza esos argumentos y almacena sus valores como cadenas en un objeto NameSpace especial bajo los atributos denominados .player_x, .player_o y .starting_mark. , respectivamente. Sin embargo, se espera que la función devuelva una tupla que consta de tipos de datos personalizados en lugar de cadenas. Para que el cuerpo de la función cumpla con su firma, puedes asignar cadenas proporcionadas por el usuario a las clases respectivas usando tu diccionario:

# frontends/console/args.py

import argparse

from tic_tac_toe.game.players import Player, RandomComputerPlayer
from tic_tac_toe.logic.models import Mark

from .players import ConsolePlayer

PLAYER_CLASSES = {
    "human": ConsolePlayer,
    "random": RandomComputerPlayer,
}

def parse_args() -> tuple[Player, Player, Mark]:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-X",
        dest="player_x",
        choices=PLAYER_CLASSES.keys(),
        default="human",
    )
    parser.add_argument(
        "-O",
        dest="player_o",
        choices=PLAYER_CLASSES.keys(),
        default="random",
    )
    parser.add_argument(
        "--starting",
        dest="starting_mark",
        choices=Mark,
        type=Mark,
        default="X",
    )
    args = parser.parse_args()

    player1 = PLAYER_CLASSES[args.player_x](Mark("X"))
    player2 = PLAYER_CLASSES[args.player_o](Mark("O"))

    if args.starting_mark == "O":
        player1, player2 = player2, player1

    return player1, player2, args.starting_mark

Usted traduce los nombres proporcionados por el usuario a clases de jugadores concretas. Si la marca del jugador inicial es diferente de la predeterminada, entonces intercambias a los dos jugadores antes de devolverlos de la función.

Para que el código sea un poco más limpio y expresivo, puedes reemplazar la tupla genérica con una tupla escrita con nombre:

# frontends/console/args.py

import argparse
from typing import NamedTuple

from tic_tac_toe.game.players import Player, RandomComputerPlayer
from tic_tac_toe.logic.models import Mark

from .players import ConsolePlayer

PLAYER_CLASSES = {
    "human": ConsolePlayer,
    "random": RandomComputerPlayer,
}

class Args(NamedTuple):
    player1: Player
    player2: Player
    starting_mark: Mark

def parse_args() -> Args:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-X",
        dest="player_x",
        choices=PLAYER_CLASSES.keys(),
        default="human",
    )
    parser.add_argument(
        "-O",
        dest="player_o",
        choices=PLAYER_CLASSES.keys(),
        default="random",
    )
    parser.add_argument(
        "--starting",
        dest="starting_mark",
        choices=Mark,
        type=Mark,
        default="X",
    )
    args = parser.parse_args()

    player1 = PLAYER_CLASSES[args.player_x](Mark("X"))
    player2 = PLAYER_CLASSES[args.player_o](Mark("O"))

    if args.starting_mark == "O":
        player1, player2 = player2, player1

    return Args(player1, player2, args.starting_mark)

Primero, define una subclase typing.NamedTuple que comprende precisamente tres elementos nombrados y escritos. Luego devuelve una instancia de su tupla con nombre en lugar de una tupla genérica. Hacerlo le brinda seguridad de tipo adicional y acceso a los elementos de la tupla por nombre y por índice.

Para jugar contra otro humano, puedes ejecutar la interfaz de tu consola con estos argumentos:

(venv) $ cd frontends/
(venv) $ python -m console -X human -O human

Si desea probar sus posibilidades contra la computadora, reemplace el valor de la opción -X o -O con random, que es actualmente el único tipo de reproductor de computadora disponible. Desafortunadamente, no es particularmente difícil jugar contra un jugador que realiza movimientos al azar. En el siguiente paso, implementará un reproductor de computadora más avanzado aprovechando el algoritmo minimax, que hace que la computadora sea prácticamente invencible.

Paso 4: equipar la computadora con inteligencia artificial

Has llegado al paso final de este tutorial, que implica crear otro reproductor de computadora, éste equipado con inteligencia artificial básica. Específicamente, utilizará el algoritmo minimax debajo de la superficie para realizar el movimiento más óptimo en cada situación posible en cualquier juego de suma cero por turnos como el tres en raya.

Antes de implementar el algoritmo, debes inventar una forma de evaluar la puntuación del juego, que se convertirá en el factor decisivo para elegir la mejor jugada. Lo harás introduciendo una escala absoluta de valores numéricos que indiquen qué tan bien lo están haciendo ambos jugadores.

Evaluar la puntuación de un juego terminado

Para simplificar, considerará la evaluación estática de un juego terminado. Hay tres resultados posibles del juego, a los que puedes asignar valores numéricos arbitrarios, por ejemplo:

  1. El jugador pierde: -1
  2. Empates de jugadores: 0
  3. El jugador gana: 1

El jugador protagonista cuya puntuación evaluarás se conoce como jugador maximizador porque intenta maximizar la puntuación general del juego. Por lo tanto, mayores valores deberían corresponder a mejores resultados, vistos desde su perspectiva. El jugador que minimiza, en cambio, es su oponente, que intenta bajar la puntuación lo máximo posible. Al fin y al cabo, ganan cuando tu jugador pierde, mientras que un empate puede ser igualmente bueno o malo para ambos jugadores.

Una vez que determines los jugadores que maximizan y minimizan, la escala permanece absoluta, lo que significa que no necesitas cambiar el signo al evaluar los movimientos de tu oponente.

Puedes expresar esta escala numérica en código Python agregando el siguiente método a tu modelo GameState en la biblioteca de tres en raya:

# tic_tac_toe/logic/models.py

import enum
import re
from dataclasses import dataclass
from functools import cached_property

from tic_tac_toe.logic.exceptions import InvalidMove, UnknownGameScore
from tic_tac_toe.logic.validators import validate_game_state, validate_grid

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    def make_move_to(self, index: int) -> Move:
        ...

    def evaluate_score(self, mark: Mark) -> int:
        if self.game_over:
            if self.tie:
                return 0
            if self.winner is mark:
                return 1
            else:
                return -1
        raise UnknownGameScore("Game is not over yet")

Debido a que se trata de una evaluación estática, solo puedes determinar la puntuación cuando finaliza el juego. De lo contrario, genera una excepción UnknownGameScore, que debe agregar al módulo exceptions en la biblioteca:

# tic_tac_toe/logic/exceptions.py

class InvalidGameState(Exception):
    """Raised when the game state is invalid."""

class InvalidMove(Exception):
    """Raised when the move is invalid."""

class UnknownGameScore(Exception):
    """Raised when the game score is unknown."""

Conocer el puntaje de un juego terminado no es tan útil cuando se quiere tomar una decisión informada sobre la elección de un movimiento inicial. Sin embargo, es el primer paso para encontrar la mejor secuencia posible de movimientos que conduzcan a ganar o empatar el juego, en el peor de los casos. A continuación, utilizarás el algoritmo minimax para calcular la puntuación en cualquier estado del juego.

Propagar la partitura con el algoritmo Minimax

Cuando tienes varios movimientos para elegir, querrás elegir uno que aumente tu puntuación esperada. Al mismo tiempo, debes evitar movimientos que potencialmente podrían cambiar la puntuación del juego a favor de tu oponente. El algoritmo minimax puede ayudar con eso usando las funciones min() y max() para minimizar la ganancia máxima de tu oponente mientras maximizas tu pago mínimo.

Si esto suena complicado, entonces eche un vistazo a una visualización gráfica del juego de tres en raya a continuación.

Cuando imaginas todos los estados posibles del juego como un árbol de juego, elegir el mejor movimiento se reduce a buscar la ruta más óptima en dicho gráfico ponderado, comenzando desde el nodo actual. El algoritmo minimax propaga las puntuaciones evaluadas estáticamente para los nodos hoja, que corresponden a juegos terminados, burbujeándolos en el árbol del juego. La puntuación mínima o máxima se propaga en cada paso, dependiendo de quién sea el turno.

Puedes visualizar este proceso usando un ejemplo concreto de los últimos tres turnos de un juego de tres en raya. A continuación, encontrarás un pequeño segmento del árbol del juego de tres en raya que ilustra los posibles movimientos del jugador maximizador X, cuyos turnos están representados en verde:

El algoritmo minimax comienza explorando recursivamente el árbol para mirar hacia adelante y encontrar todos los resultados posibles del juego. Una vez que se encuentran, calcula sus puntuaciones y retrocede hasta el nodo inicial. Si es el turno del jugador que maximiza el que lleva a la siguiente posición, entonces el algoritmo elige la puntuación máxima en ese nivel. De lo contrario, elige la puntuación mínima, asumiendo que el oponente nunca cometerá errores.

En el árbol de juego de arriba, la rama más a la izquierda da como resultado una victoria inmediata para el jugador que maximiza, por lo que el borde de conexión tiene el mayor peso. Elegir la rama intermedia también podría conducir a una victoria, pero el algoritmo minimax pesimista indica el peor de los casos, que es un empate. Finalmente, la rama de la derecha casi con certeza representa un movimiento perdedor.

Cree un nuevo módulo minimax en la biblioteca tic-tac-toe e implemente el algoritmo usando la siguiente elegante expresión de Python:

# tic_tac_toe/logic/minimax.py

from tic_tac_toe.logic.models import Mark, Move

def minimax(
    move: Move, maximizer: Mark, choose_highest_score: bool = False
) -> int:
    if move.after_state.game_over:
        return move.after_state.evaluate_score(maximizer)
    return (max if choose_highest_score else min)(
        minimax(next_move, maximizer, not choose_highest_score)
        for next_move in move.after_state.possible_moves
    )

La función minimax() devuelve la puntuación asociada con el movimiento pasado como argumento para el jugador maximizador indicado. Si el juego ha terminado, entonces calculas la puntuación realizando la evaluación estática de la cuadrícula. De lo contrario, eliges la puntuación máxima o mínima, que encontrarás recursivamente para todos los movimientos posibles en la posición actual.

Siempre que haya realizado una instalación editable de la biblioteca de tres en raya en su entorno virtual, podrá probar su nueva función en una sesión interactiva de intérprete de Python:

>>> from tic_tac_toe.logic.minimax import minimax
>>> from tic_tac_toe.logic.models import GameState, Grid, Mark

>>> def preview(cells):
...     print(cells[:3], cells[3:6], cells[6:], sep="\n")

>>> game_state = GameState(Grid("XXO O X O"), starting_mark=Mark("X"))
>>> for move in game_state.possible_moves:
...     print("Score:", minimax(move, maximizer=Mark("X")))
...     preview(move.after_state.grid.cells)
...     print("-" * 10)

Score: 1
XXO
XO
X O
----------
Score: 0
XXO
 OX
X O
----------
Score: -1
XXO
 O
XXO
----------

Las puntuaciones calculadas corresponden a los pesos de los bordes en el árbol del juego que viste antes. Encontrar la mejor jugada es sólo cuestión de elegir la que tenga la puntuación resultante más alta. Tenga en cuenta que a veces puede haber múltiples caminos alternativos hacia un resultado ganador en el árbol del juego.

En la siguiente sección, creará otro reproductor de computadora concreto, que aprovechará el algoritmo minimax y luego lo usará en la interfaz de su consola.

Crea un reproductor de computadora Minimax invencible

El algoritmo minimax calcula la puntuación asociada con un movimiento en particular. Para encontrar el mejor movimiento en un estado de juego determinado, puedes ordenar todos los movimientos posibles por puntuación y elegir el que tenga el valor más alto. Al hacer eso, usarás IA para crear un jugador de tres en raya inmejorable con Python.

Continúe y defina la siguiente función en el módulo minimax de su biblioteca de tres en raya:

# tic_tac_toe/logic/minimax.py

from functools import partial

from tic_tac_toe.logic.models import GameState, Mark, Move

def find_best_move(game_state: GameState) -> Move | None:
    maximizer: Mark = game_state.current_mark
    bound_minimax = partial(minimax, maximizer=maximizer)
    return max(game_state.possible_moves, key=bound_minimax)

def minimax(
    move: Move, maximizer: Mark, choose_highest_score: bool = False
) -> int:
    ...

La función find_best_move() toma algún estado del juego y devuelve el mejor movimiento para el jugador actual o Ninguno para indicar que no hay más movimientos posibles. . Tenga en cuenta el uso de una función parcial para congelar el valor del argumento maximizer, que no cambia entre las invocaciones de minimax(). Esto le permite utilizar la función bound_minimax(), que espera exactamente un argumento, como clave de pedido.

A continuación, agregue un nuevo reproductor de computadora en el módulo players de la biblioteca de tres en raya. Este reproductor utilizará la función auxiliar find_best_move() que acaba de crear:

# tic_tac_toe/game/players.py

import abc
import random
import time

from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.minimax import find_best_move
from tic_tac_toe.logic.models import GameState, Mark, Move

class Player(metaclass=abc.ABCMeta):
    ...

class ComputerPlayer(Player, metaclass=abc.ABCMeta):
    ...

class RandomComputerPlayer(ComputerPlayer):
    def get_computer_move(self, game_state: GameState) -> Move | None:
        try:
            return random.choice(game_state.possible_moves)
        except IndexError:
            return None

class MinimaxComputerPlayer(ComputerPlayer):
    def get_computer_move(self, game_state: GameState) -> Move | None:
        return find_best_move(game_state)

Este jugador de computadora siempre intentará encontrar el mejor movimiento de tres en raya con IA y Python. Sin embargo, para hacer el juego menos predecible y reducir la cantidad de cálculo, puedes dejar que elija el primer movimiento al azar antes de ejecutar el costoso algoritmo minimax. Ya implementaste la lógica para elegir un movimiento aleatorio en RandomComputerPlayer, definida anteriormente. Ahora, sería útil extraer esa lógica común en un componente reutilizable.

Continúe y modifique el código de los reproductores de computadora aleatorios y minimax:

 # tic_tac_toe/game/players.py

 import abc
-import random
 import time

 from tic_tac_toe.logic.exceptions import InvalidMove
 from tic_tac_toe.logic.minimax import find_best_move
 from tic_tac_toe.logic.models import GameState, Mark, Move

 class Player(metaclass=abc.ABCMeta):
     ...

 class ComputerPlayer(Player, metaclass=abc.ABCMeta):
     ...

 class RandomComputerPlayer(ComputerPlayer):
     def get_computer_move(self, game_state: GameState) -> Move | None:
-        try:
-            return random.choice(game_state.possible_moves)
-        except IndexError:
-            return None
+        return game_state.make_random_move()

 class MinimaxComputerPlayer(ComputerPlayer):
     def get_computer_move(self, game_state: GameState) -> Move | None:
-        return find_best_move(game_state)
+        if game_state.game_not_started:
+            return game_state.make_random_move()
+        else:
+            return find_best_move(game_state)

Llamas al método .make_random_move() en el estado del juego en ambas clases. Debes definir este nuevo método para elegir uno de los posibles movimientos usando el módulo random de Python:

# tic_tac_toe/logic/models.py

import enum
import random
import re
from dataclasses import dataclass
from functools import cached_property

from tic_tac_toe.logic.exceptions import InvalidMove
from tic_tac_toe.logic.validators import validate_game_state, validate_grid

# ...

@dataclass(frozen=True)
class GameState:
    # ...

    @cached_property
    def possible_moves(self) -> list[Move]:
        ...

    def make_random_move(self) -> Move | None:
        try:
            return random.choice(self.possible_moves)
        except IndexError:
            return None

    def make_move_to(self, index: int) -> Move:
        ...

    def evaluate_score(self, mark: Mark) -> int:
        ...

El último paso es utilizar el nuevo reproductor de computadora en su interfaz. Abra el módulo args en el proyecto de interfaz de usuario de la consola e importe MinimaxComputerPlayer:

# frontends/console/args.py

import argparse
from typing import NamedTuple

from tic_tac_toe.game.players import (
    Player,
    RandomComputerPlayer,
    MinimaxComputerPlayer,
)
from tic_tac_toe.logic.models import Mark

from .players import ConsolePlayer

PLAYER_CLASSES = {
    "human": ConsolePlayer,
    "random": RandomComputerPlayer,
    "minimax": MinimaxComputerPlayer,
}

class Args(NamedTuple):
    player1: Player
    player2: Player
    starting_mark: Mark

def parse_args() -> Args:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-X",
        dest="player_x",
        choices=PLAYER_CLASSES.keys(),
        default="human",
    )
    parser.add_argument(
        "-O",
        dest="player_o",
        choices=PLAYER_CLASSES.keys(),
        default="minimax",
    )

    # ...

Agrega el nuevo tipo de jugador al mapeo de nombres y usa el jugador de computadora minimax como oponente predeterminado del jugador humano.

Bien, ahora tienes tres tipos de jugadores para elegir. Puedes probar la interfaz de tu consola seleccionando diferentes jugadores para probar sus posibilidades unos contra otros. Por ejemplo, puedes elegir dos reproductores de computadora minimax:

(venv) $ cd frontends/
(venv) $ python -m console -X minimax -O minimax

En este caso, debes esperar que el juego siempre termine en empate, ya que ambos jugadores utilizan la estrategia óptima.

Una cosa que puedes notar al solicitar al menos un reproductor minimax es un rendimiento bastante pobre, especialmente al comienzo del juego. Esto se debe a que construir todo el árbol del juego, incluso para un juego tan relativamente básico como el tres en raya, es muy costoso. Explorará algunas posibilidades de optimización del rendimiento en los próximos pasos.

¡Felicidades! Has llegado al final de este largo viaje. No se olvide de los materiales de apoyo, que contienen código adicional que no se cubrió en el tutorial. Los materiales incluyen otros dos frontales y algunos trucos de rendimiento, que hacen que el jugador minimax realice sus movimientos al instante. Puede descargar este código haciendo clic en el siguiente enlace:

Conclusión

¡Hiciste un trabajo fantástico al completar este tutorial detallado paso a paso! Has creado una biblioteca de tres en raya independiente del front-end con la lógica central del juego y dos reproductores informáticos artificiales, incluido uno imbatible que aprovecha el algoritmo minimax. También creó una interfaz de muestra que representa el juego en la consola basada en texto y recibe información de un jugador humano.

En el camino, siguió buenas prácticas de programación, incluido el diseño orientado a objetos con elementos del paradigma funcional, y aprovechó las últimas mejoras. en el lenguaje Python.

En este tutorial, has aprendido cómo:

  • Cree una biblioteca Python reutilizable con el motor del juego tres en raya
  • Modelar el dominio del tres en raya siguiendo el estilo del código Pythonic
  • Implementar reproductores artificiales, incluido uno basado en el algoritmo minimax
  • Cree una frontal de consola basada en texto para el juego con un jugador humano
  • Explora estrategias para optimizaciones de rendimiento

Si aún no lo ha hecho, haga clic en el enlace a continuación para descargar la fuente completa y algún código adicional para el proyecto que ha estado creando en este tutorial:

Próximos pasos

Tener una biblioteca tres en raya genérica de Python con la lógica central del juego y la IA te permite centrarte en crear interfaces alternativos que puedan aprovechar diferentes interfaces gráficas. En este tutorial, ha creado una interfaz de consola basada en texto para tres en raya, mientras que los materiales de apoyo contienen ejemplos de otras capas de presentación. Tal vez quieras crear uno para Jupyter Notebook o un teléfono móvil usando Kivy u otro marco de Python.

Un área importante de mejora es el cuello de botella en el rendimiento derivado de la naturaleza de fuerza bruta del algoritmo minimax, que comprueba todos los estados posibles del juego. Hay varias formas de reducir la cantidad de cálculos y acelerar el proceso:

  • Una heurística: en lugar de explorar toda la profundidad del árbol, puede detenerse en un nivel designado y estimar una puntuación aproximada con una heurística. Vale la pena señalar que esto a veces puede dar resultados subóptimos.
  • Almacenamiento en caché: puedes precalcular todo el árbol del juego desde el principio, lo que sería un esfuerzo único que requeriría muchos recursos. Más tarde, podrás cargar la tabla de búsqueda (LUT) en la memoria y obtener la puntuación instantáneamente para cada estado posible del juego.
  • Poda alfa-beta: es posible descartar una parte significativa de los nodos en el árbol del juego como malas elecciones al explorarlo con el algoritmo minimax. Puede emplear una ligera modificación del algoritmo minimax, conocida como técnica de poda alfa-beta. En resumen, realiza un seguimiento de las mejores opciones que ya están disponibles sin ingresar a sucursales que garantizan ofrecer peores opciones.

¿Tiene otras ideas para utilizar o ampliar la biblioteca de tres en raya? ¡Compártelos en los comentarios a continuación!

Artículos relacionados