clavier ouvert

Blog d'enseignement d'Adrien Foucart.
Toutes les opinions présentées ici n'engagent que moi. Blog garanti sans pub, sans traqueurs, et 100% rédigé par un humain.

2026.05.19
Spirales et chevaliers | 4. plus joli, plus pratique

Le code est maintenant disponible sur https://codeberg.org/adfoucart/spirals.

Nous sommes maintenant capable de calculer le pattern pour (au moins) deux “joueurs” avec cavalier:

1000 premiers points, deux joueurs, cavalier pour les deux

Mais le code commence à devenir un peu brouillon. Refactorisons.

Module vue

Première chose: prenons les codes de visualisation et donnons leur leur propre module, view.py:

# view.py
from typing import Iterable
from matplotlib import pyplot as plt

from player import Player

def show_points_with_lines(positions: Iterable[tuple[int, int]]) -> None:
    plt.figure()
    i = 0
    for (x1, y1), (x2, y2) in itertools.pairwise(positions):
        if i >= 10:
            break
        plt.plot(x1, y1, 'bo')
        plt.plot([x1, x2], [y1, y2], 'k-')
        i += 1
    plt.show()

def show_points(positions: Iterable[tuple[int, int, Player]]) -> None:
    plt.figure()
    for x, y, player in positions:
        plt.plot(x, y, f'{player.color}+')
    plt.axis('equal')
    plt.show()

Cela nous permet d’alléger spiral.py, qui a terme devrait uniquement se préoccuper de récupérer la configuration à afficher, et d’appeler des fonctions des différents modules.

Visualisation du plateau

Je n’aime pas trop le fait d’envoyer ce générateur à show_point. Ni d’ailleurs d’afficher l’image avec des plt.plot. Outre le fait que ce n’est pas très joli, cela prend beaucoup de temps à générer si on augmente le nombre de points. Je préfèrerais pouvoir visualiser le plateau comme une image, où chaque position correspond à un pixel, et contrôler le nombre de “couches” de la spirale à afficher. Une fonction comme:

# view.py
def show_board(board: Board, layers: int) -> None:
    ...

Pour créer l’image, on peut alors parcourir tous les pixels à générer et récupérer le joueur qui se trouve (ou non) à cette position. On doit d’abord modifier board.py pour implémenter __getitem__, et ainsi pouvoir obtenir le joueur d’une position via board[pos]:

# board.py
class Board:
    # ...
    def __getitem__(self, position: tuple[int, int]) -> Player:
        return self.values[position]

Et la vue:

# view.py
def show_board(board: Board, layers: int) -> None:
    image = []
    for y in range(-layers, layers+1):
        row = []
        for x in range(-layers, layers+1):
            player = board[(x, y)]
            row.append(player)
        image.append(row)
    plt.figure()
    plt.imshow(image)
    plt.show()

Le problème, maintenant, est que matplotlib ne sait pas traduire des Player en couleur. Je ne peux pas non plus juste utiliser player.color: il me faut des valeurs numériques pour les pixels. Modifions un peu Player pour qu’à chaque joueur soit attribué un identifiant unique, qu’on va générer automatiquement à chaque fois qu’on instancie un nouveau joueur.

# player.py
from typing import Iterable
from dataclasses import dataclass, field

_current_id = 0
def nextid():
    global _current_id
    _current_id += 1
    return _current_id

@dataclass
class Player:
    color: str
    offsets: Iterable[tuple[int, int]]
    _id: int = field(default_factory=nextid)

    @property
    def id(self):
        return self._id

On peut maintenant utiliser cet id dans show_board, en traitant au passage le cas None pour mettre la valeur 0:

# view.py
def show_board(board: Board, layers: int) -> None:
    image = []
    for y in range(-layers, layers+1):
        row = []
        for x in range(-layers, layers+1):
            player = board[(x, y)]
            if player is None:
                row.append(0)
            else:
                row.append(player.id)
        image.append(row)
    plt.figure()
    plt.imshow(image)
    plt.show()

Génération par couche

On génère pour l’instant toujours un “nombre de points”. Pour s’accorder avec show_board, ce serait beaucoup plus pratique de donner un nombre de couche. Et vu la façon dont le code est en train de s’organiser, je pense que ça devrait être la responsabilité de board de se remplir. Mettons le générateur de points de la spirale dans un fichier séparé, pour mieux organiser les choses:

# generators.py
from typing import Generator
import itertools

LEFT = (-1, 0)
RIGHT = (1, 0)
UP = (0, 1)
DOWN = (0, -1)
SPIRAL_CYCLE = [(RIGHT, 1), (UP, 0), (LEFT, 1), (DOWN, 0)]

def position_generator() -> Generator[tuple[int, int], None, None]:
    x, y = 0, 0
    dx, dy = 0, 0
    length = 0
    current_pos_in_segment = 0
    cycle = itertools.cycle(SPIRAL_CYCLE)
    
    while True:
        yield x, y 
        if current_pos_in_segment == length:
            direction, dl = next(cycle)
            dx, dy = direction
            length += dl
            current_pos_in_segment = 0
        x += dx
        y += dy
        current_pos_in_segment += 1

Et transférons _next_valid_point_for_player dans board, pour pouvoir également ajouter une méthode fill. On veut s’arrêter dès que tous les générateurs des différents joueurs ont dépassé la couche maximale que je voudrai afficher. On va donc retenir la “couche actuelle” de chaque joueur dans un dictionnaire, et la mettre à jour à chaque fois qu’on place un point:

# board.py
class Board:
    # ...
    def _next_valid_point_for_player(self,
                                        points: Iterable[tuple[int, int]],
                                        players: Iterable[Player]) -> tuple[int, int]:
        for x, y in points:
            if not self.is_available((x, y)): 
                continue
            if any(self.is_visible_from((x, y), player) for player in players):
                continue
            return x, y
        
    def fill(self, max_layers: int, players: Iterable[Player]) -> None:
        n_points = 0
        generators = [(player, position_generator()) for player in players]
        layer_per_player = {}
        for player in players:
            layer_per_player[player.id] = 0
        while any(layer <= max_layers for layer in layer_per_player.values()):
            for player, generator in generators:
                if len(players) == 1:
                    x, y = self._next_valid_point_for_player(generator, player)
                else:
                    x, y = self._next_valid_point_for_player(generator, [p for p in players if p != player])
                self.put((x, y), player)
                n_points += 1
                layer_per_player[player.id] = max(abs(x), abs(y))

Ceci nous permet de considérablement simplifier spiral.py:

from board import Board
from player import Player
from view import show_points, show_board

if __name__ == "__main__":
    board = Board()
    players = [
        Player(color='r', offsets=[(1, 2), (-1, 2), (2, 1), (-2, 1), (1, -2), (-1, -2), (2, -1), (-2, -1)]),
        Player(color='k', offsets=[(1, 2), (-1, 2), (2, 1), (-2, 1), (1, -2), (-1, -2), (2, -1), (-2, -1)])
    ]
    board.fill(20, players)
    show_board(board, 20)

Voyons ce que ça donne:

20 premières couches, deux joueurs, cavalier pour les deux

Point positif: on a bien des pixels au lieu des croix, le rendu est globalement meilleur. Point négatif: on a perdu les couleurs qu’on avait définies dans Player: plt.imshow utilise une colormap pour choisir les couleurs en fonction de la valeur, et pour l’instant on est dans la colormap par défaut, viridis.

Les bonnes couleurs

Redéfinir une colormap n’est heureusement pas compliqué. Petite modification de view.py:

# view.py
from matplotlib.colors import ListedColormap 

def show_board(board: Board, max_layers: int):
    # ...
    cmap = ListedColormap(['w'] + [p.color for p in board.players]) 
    # ...
    plt.imshow(image, cmap=cmap) 
    plt.show()

Il faut cependant pour ça que Board connaisse la liste des players, ce qui n’est pas encore le cas. Au passage, on peut aussi lui faire retenir max_layers, ce qui va nous permettre de simplifier plusieurs choses. En fait, on peut même initialiser tout ça dans le constructeur, et laisser le constructeur appeler fill:

# board.py
class Board:
    def __init__(self, max_layers: int, players: Iterable[Player]):
        self.values: dict[tuple[int, int], Player | None] = defaultdict(lambda: None)
        self.players = players
        self.max_layers = max_layers
        self._fill()
    # ...
    def _fill(self) -> None:
        n_points = 0
        generators = [(player, position_generator()) for player in self.players]
        layer_per_player = {}
        for player in self.players:
            layer_per_player[player.id] = 0
        while any(layer <= self.max_layers for layer in layer_per_player.values()):
            for player, generator in generators:
                if len(self.players) == 1:
                    x, y = self._next_valid_point_for_player(generator, player)
                else:
                    x, y = self._next_valid_point_for_player(generator, [p for p in self.players if p != player])
                self.put((x, y), player)
                n_points += 1
                layer_per_player[player.id] = max(abs(x), abs(y))

Le main devient alors:

# spiral.py
if __name__ == "__main__":
    players = [
        Player(color='r', offsets=[(1, 2), (-1, 2), (2, 1), (-2, 1), (1, -2), (-1, -2), (2, -1), (-2, -1)]),
        Player(color='k', offsets=[(1, 2), (-1, 2), (2, 1), (-2, 1), (1, -2), (-1, -2), (2, -1), (-2, -1)])
    ]
    board = Board(20, players)
    show_board(board)

Et show_board peut également utiliser le max_layers fourni par board:

# view.py
def show_board(board: Board) -> None:
    image = []
    for y in range(-board.max_layers, board.max_layers+1):
        row = []
        for x in range(-board.max_layers, board.max_layers+1):
            player = board[(x, y)]
            if player is None:
                row.append(0)
            else:
                row.append(player.id)
        image.append(row)
    cmap = ListedColormap(['w'] + [p.color for p in board.players])
    plt.figure()
    plt.imshow(image, cmap=cmap)
    plt.show()

Ce qui nous donne:

20 premières couches, deux joueurs, cavalier pour les deux, avec les bonnes couleurs

On peut maintenant tester des configurations différentes. Par exemple: quatre joueurs, et afficher les 500 premières couches:

500 premières couches, quatre joueurs, cavalier pour tout le monde

Ca prend quelques secondes à calculer, mais là on arrive dans quelque chose d’intéressant!

Image

Dernière petite modification avant de marquer une pause: plutôt que d’afficher un plot, on peut sauvegarder directement l’image du plateau dans un fichier png.

# view.py
import pathlib

def show_board(board: Board, save_as: pathlib.Path | None = None) -> None:
    image = compute_image_for_board(board)
    cmap = ListedColormap(['w'] + [p.color for p in board.players])

    if save_as is not None:
        plt.imsave(save_as, image, cmap=cmap)

    plt.figure()
    plt.imshow(image, cmap=cmap)
    plt.show()

# spiral.py
import pathlib

if __name__ == "__main__":
    # ...
    show_board(board, save_as=pathlib.Path("result.png"))

Ce qui donne un résultat un peu plus propre.

500 premières couches, quatre joueurs, cavalier pour tout le monde

On y est presque. Reste à pouvoir définir la configuration dans un fichier, peut-être encore un peu de refactorisation pour la forme, et puis tester ce que ça donne avec des pièces et configurations différentes !

Commentaires, remarques, erreurs qu'il faut absolument me faire remarquer? Contactez-moi sur Mastodon ou par mail (adrien@adfoucart.be)