♟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:
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._idOn 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 += 1Et 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:
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:
On peut maintenant tester des configurations différentes. Par exemple: quatre joueurs, et afficher les 500 premières couches:
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.
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 !