Quelque part dans un vieux parc d'attractions, un minigolf a été construit sur une grille. Chaque case
est un cube : herbe, sable, glace, eau, vide. Le propriétaire a perdu le code qui le faisait tourner. Il reste
les plans (des images PNG où chaque pixel est une case), quelques textures, et une règle gravée sur le premier
trou : « Celui qui veut jouer ici doit d'abord reconstruire le parcours, case par case. »
Vous êtes quatre. Pendant un semestre, vous allez reconstruire le moteur du jeu : lire les plans, faire
rouler une balle, la faire sauter d'un tremplin, afficher le tout en 3D, et permettre à quatre joueurs de s'affronter
sur un réseau local, contre des bots qui ne trichent pas.
Danger(s)
Règle du projet : pas de code généré par une IA. Ce sujet a été préparé avec l'aide d'une IA ;
le code, c'est vous. Vous avez le droit de demander à une IA d'expliquer une notion, un message
d'erreur, une ligne de la documentation d'Ursina. Vous n'avez pas le droit de lui demander d'écrire
une fonction du sujet, ni de coller votre code pour qu'elle le corrige.
Le test est simple : à la soutenance, chacun explique son code ligne par ligne, et le jury peut
demander une petite modification en direct.
Warning(s)
Les prototypes (noms de fichiers, de fonctions, de classes, de paramètres) doivent être
recopiés exactement : la moulinette (tests/) et vos coéquipiers s'en servent. Un mauvais
prototype, c'est un test qui échoue et un collègue bloqué.
Les print(...) des exemples servent à tester à la main. Ne les laissez pas dans le code rendu.
Information
Comment lire un exercice. Chaque exercice donne : le fichier, l'énoncé, parfois un Cours ou
un Pourquoi ?, des encadrés Information / Warning / Danger, des indices
repliés (à ouvrir dans l'ordre, après 15 minutes de recherche), le prototype à recopier, et des
exemples avec la sortie attendue exacte.
Suivi. En haut de chaque exercice, choisissez votre statut : À faire, En cours,
Bloqué, Fait. Toute l'équipe le voit en direct (onglet L'équipe).
« Bloqué » n'est pas une honte : c'est un appel à l'aide, et l'onglet Équipe le met en avant.
Ne cochez « Fait » que quand les tests de l'exercice passent.
Moulinette. Les exercices qui ont des tests peuvent aussi être validés automatiquement : la moulinette
(la CI GitLab, à chaque fusion dans la branche principale) envoie son rapport à ce site. Ils affichent alors
« moulinette ✓ » avec le commit qui les a validés.
Connexion. Onglet L'équipe : votre prénom et le mot de passe de l'équipe. Reprenez le même prénom
sur chaque appareil.
Le dépôt
Information
Un dépôt Git pour l'équipe (GitLab ou GitHub de l'école). Une branche par personne et par chapitre
(c04-physique), une fusion dans main quand les tests du chapitre passent.
On ne fusionne jamais une branche qui casse les tests des autres.
À la fin du semestre, le dépôt doit avoir exactement cette architecture. Vous la construisez
vous-mêmes au chapitre 00, et les documents de docs/ expliquent pourquoi elle est découpée ainsi.
Architecture attendue
cubogolf/
├── jouer.py lance le jeu
├── pytest.ini
├── requirements.txt
├── .gitignore
├── README.md
├── journal.md
├── assets/ (fourni) trous, balles, textures, palette
├── docs/ (fourni) les « pourquoi » du projet
├── shared/ Python pur : sert à tout le monde
│ ├── __init__.py
│ ├── constants.py (fourni)
│ ├── course_loader.py chapitre 01
│ ├── balles.py chapitres 02 et 13
│ ├── scores.py chapitre 02
│ ├── physics.py chapitres 04, 06, 07
│ └── protocol.py chapitre 08
├── ai/
│ ├── __init__.py
│ └── bot_golfer.py chapitre 10
├── server/
│ ├── __init__.py
│ ├── salon.py chapitres 09 et 10
│ └── server.py (fourni)
├── client/
│ ├── __init__.py
│ ├── textures.py (fourni)
│ ├── world_builder.py (fourni, sauf la version cubes : chapitre 03)
│ ├── camera_aim.py chapitres 03 et 05
│ ├── hud.py chapitres 03, 05, 13
│ ├── reglages.py chapitre 02
│ ├── net_client.py chapitre 11
│ ├── menu.py chapitre 12
│ └── main.py chapitres 03, 05, 11, 12, 13
├── tests/ (fourni) la moulinette
└── tools/ (fourni) outils de test et de contenu
Le .gitignore doit être en place avant le premier commit : sinon le dossier .venv (des milliers de fichiers) part dans le dépôt.
Le jeu à la fin du semestre
Version
Ce qui marche
Semaine
V0
Un trou s'affiche en 3D (des cubes), la caméra tourne autour.
3
V1
On vise, on tire, la balle roule et rebondit sur les murs ; on joue seul.
5
V2
Sable, glace, eau, vide, herbe haute.
6
V3
Rampes et tremplins : la balle saute par-dessus le vide.
7
V4
Partie à plusieurs PC sur le réseau local, avec chrono et scores.
8
V5
9 trous, bots en 3 niveaux, menus, balles personnalisées, un .exe.
11-13
flowchart LR
constants --> course_loader --> physics --> bot_golfer --> salon --> server
protocol --> salon
protocol --> net_client --> main
physics --> main
world_builder --> main
camera_aim --> main
hud --> main
menu --> main
reglages --> menu
Pourquoi ?
Le graphe ci-dessus est l'architecture : une flèche A → B veut dire « B utilise A ».
Il n'y a aucune boucle : la physique ne sait pas qu'il existe un affichage, le serveur ne sait pas qu'il existe
une fenêtre. C'est ce qui permet de tester chaque pièce seule, et de travailler à quatre sans se marcher dessus.
Tout le raisonnement est dans docs/01_architecture.md.
Les rôles
Personne
Rôle
Chapitres
1
Physique et parcours
01, 04, 06, 07, équilibrage (14)
2
Rendu 3D et contrôles
03, 05, 12, affichage (13), textures (15)
3
Réseau
08, 09, 11
4
IA, contenu et livraison
02, 10, 13, 14, le .exe (15)
Information
Choisissez votre rôle dans l'onglet L'équipe et la date de début dans Planning. Le chapitre 00 se fait tous ensemble.
Avant de poser la première case, il faut un terrain : quatre machines identiques, un dépôt commun, et des dossiers rangés pour que chacun sache où construire. Une équipe qui saute cette étape passe le semestre à se demander pourquoi « ça marche chez moi ».
Objectif
À la fin de la séance : les 4 PC ont la même version de Python, le dépôt Git existe, l'architecture du projet est créée à la main, les fichiers fournis sont rangés, et la moulinette tourne (en rouge : c'est normal, il n'y a pas encore de code).
Cours · Module, package, import
Un module, c'est un fichier .py. Un package, c'est un dossier de modules qui contient un fichier
__init__.py (souvent vide).
Quand vous écrivez from shared.physics import pas, Python :
cherche un dossier shared dans le dossier d'où vous avez lancé le programme ;
cherche le fichier physics.py dans ce dossier ;
cherche le nom pas dans ce fichier.
Conséquence : on lance toujours le projet depuis la racine (python jouer.py, python -m pytest),
jamais depuis un sous-dossier.
Cours · Environnement virtuel
Un venv est une copie légère de Python, propre au projet, avec ses bibliothèques à lui. Les 4 PC installent
exactement les mêmes versions (requirements.txt) : fini le « chez moi ça marche ». Le dossier .venv ne
va jamais dans Git : chacun le recrée sur son PC.
Cours · La moulinette
Des tests déjà écrits (dossier tests/) vérifient votre code. En plus, la moulinette exécute les exemples
du sujet sur votre code et compare la sortie à la sortie attendue, caractère par caractère (comme les « Output » de ce sujet).
Un exercice est validé quand tout passe. python tools/progression.py affiche le tableau.
Pourquoi ?
Pourquoi découper le jeu en shared/, ai/, server/, client/ ? Parce que le serveur et les tests doivent utiliser la physique sans ouvrir de fenêtre, et que quatre personnes doivent pouvoir travailler sans modifier les mêmes fichiers. Lisez docs/01_architecture.md à quatre.
Partie A — les outils
les 4 PC
Le même Python partout
Installez la même version de Python (3.12 ou plus récente) sur les 4 PC.
Étapes
Téléchargez Python sur python.org (la même version pour tous).
Sous Windows, cochez « Add Python to PATH » pendant l'installation.
Dans un terminal neuf : python --version.
Notez la version dans le journal de l'équipe (onglet L'équipe).
Vérification
Les 4 PC affichent la même version (au moins les deux premiers nombres, par exemple 3.14).
GitLab
Créer le dépôt
Une seule personne crée le dépôt sur la forge de l'école et invite les trois autres.
Étapes
Créez un projet vide (sans README) sur la forge GitLab de l'école.
Members : invitez les trois autres avec le rôle Maintainer.
Copiez l'adresse git clone du projet dans le journal.
Vérification
Les 4 membres voient le projet dans leur liste GitLab.
les 4 PC
Cloner et se présenter à Git
Chacun récupère le dépôt sur son PC et dit à Git qui il est (pour signer ses commits).
Étapes
git clone <adresse> dans votre dossier de travail.
git status dans le dossier répond « On branch main » (ou master).
.gitignore
Le .gitignore
Le .gitignore liste ce que Git ne doit jamais envoyer : l'environnement virtuel, les fichiers compilés,
les réglages personnels. Copiez celui des consignes (page d'accueil) à la racine, puis faites le premier commit.
Étapes
Créez .gitignore à la racine du dépôt, collez le contenu des consignes.
git add .gitignore puis git commit -m "gitignore".
git push.
Danger(s)
Sans .gitignore, le premier git add . envoie le dossier .venv : des milliers de fichiers, et un dépôt inutilisable.
Vérification
Sur GitLab, le dépôt contient .gitignore et rien d'autre.
journal.md
Branche, commit, fusion, conflit
Chacun à son tour fait le tour complet d'une modification : branche, commit, push, fusion.
Étapes
git pull pour avoir la dernière version.
git switch -c essai-prenom : une branche à vous.
Ajoutez une ligne à la fin de journal.md (créez-le s'il n'existe pas) : votre prénom et votre rôle.
Le deuxième qui fusionne a un conflit dans journal.md (deux lignes ajoutées au même endroit). Résolvez-le ensemble : gardez les deux lignes. C'est le but de l'exercice.
Vérification
git log --oneline main montre un commit de chaque membre.
.venv/
L'environnement virtuel
Créez l'environnement virtuel du projet, à la racine du dépôt, et activez-le.
Étapes
python -m venv .venv
Windows : .venv\Scripts\activate — Linux / macOS : source .venv/bin/activate.
(.venv) apparaît au début de la ligne du terminal.
VS Code : Python : Select Interpreter puis celui de .venv.
Information
À refaire dans chaque nouveau terminal : l'activation ne dure que le temps du terminal.
Vérification
python -c "import sys; print(sys.prefix)" affiche un chemin qui finit par .venv.
requirements.txt
Les bibliothèques
Créez requirements.txt à la racine avec exactement ces trois lignes, puis installez.
Commit et push du fichier (pas du dossier .venv !).
Code fourni
Code fourni : à recopier tel quel
ursina==8.3.0
pillow==13.0.0
pytest==9.1.1
Vérification
python -c "import ursina, PIL, pytest; print('ok')" affiche ok.
Partie B — l'architecture
shared/ client/ server/ ai/
Créer les quatre packages
Créez à la racine les dossiers shared, client, server, ai, chacun avec un fichier
__init__.pyvide. Ne créez pas encore les autres fichiers : chaque chapitre dit lesquels écrire.
Pourquoi ?
Un dossier sans __init__.py marche souvent quand même, mais pas avec tous les outils, et pas dans un .exe. Le fichier vide rend le package explicite.
Vérification
Comparez avec l'arborescence des consignes : mêmes noms, mêmes endroits.
journal.md
Qui a le droit d'importer qui ?
Lisez docs/01_architecture.md puis répondez par écrit dans le journal, à quatre :
Quel dossier a le droit d'importer Ursina ?
Lequel n'a le droit d'importer que shared (et la bibliothèque standard) ?
Pourquoi server n'importe-t-il jamais client ?
Pourquoi les tests peuvent-ils tourner sur un serveur sans écran ?
Hint(s)
Indice 1 Une flèche = un import
Dessinez les quatre dossiers et une flèche de A vers B si A importe B. Il ne doit y avoir aucune boucle, et aucune flèche ne doit partir de shared.
fournis/ → racine
Ranger les fichiers fournis
Le dossier de départ contient fournis/. Copiez chaque élément à sa place (même chemin, sans le préfixe fournis/) :
Fourni
Pourquoi il est fourni
assets/
les trous (PNG), les balles, les textures, la palette : du contenu, pas du code
docs/
les « pourquoi » du projet, à lire au fil des chapitres
tests/
la moulinette : les tests et les exemples du sujet
tools/
des outils (aperçu, trajectoires, faux joueurs...) et la moulinette (progression.py)
.gitlab-ci.yml, requirements-ci.txt
la moulinette sur GitLab, à chaque push
shared/constants.py
tous les réglages du jeu : des valeurs, pas de logique
server/server.py
sockets et threads côté serveur : hors programme, mais commenté
client/textures.py
charger un PNG en texture Ursina : 20 lignes de plomberie
client/world_builder.py
le maillage texturé (géométrie 3D avancée) ; la version cubes, c'est vous
Danger(s)
Ne modifiez jamaistests/. Si un test vous semble faux, parlez-en au groupe.
Vérification
git status montre les nouveaux fichiers ; commit et push.
shared/constants.py
Lire les constantes
Lisez shared/constants.py en entier, à deux, à voix haute. Répondez dans le journal :
Quelle couleur a une case de sable ? Une rampe qui monte vers l'Est ?
Combien de cases une balle tirée à 100 % peut-elle parcourir en une seconde ?
Pourquoi FROTTEMENT de la glace est-il plus petit que celui de l'herbe ?
Que se passe-t-il au 10e coup ?
pytest.ini
Brancher pytest
Créez pytest.ini à la racine. Il dit à pytest où sont les tests, et que la racine du projet doit être dans le chemin des
imports (sinon from shared... échoue dans les tests).
python -m pytest se termine par une ligne de résumé (beaucoup d'errors : normal, il n'y a pas de code).
jouer.py
Le point d'entrée
Créez jouer.py à la racine : c'est le fichier qu'on lance pour jouer. Il ne contient rien d'autre que l'appel au client.
Information
Il ne marchera qu'au chapitre 03, quand client/main.py existera.
Code fourni
Code fourni : à recopier tel quel
"""Lance Cubogolf."""
from client.main import main
main()
tools/progression.py
Lancer la moulinette
Lancez la moulinette chez vous et apprenez à la lire.
Étapes
python tools/progression.py : le tableau des exercices validés, chapitre par chapitre (tout à 0).
python tools/progression.py 1 : le détail du chapitre 1, avec les tests et les exemples qui restent à faire passer.
python -m pytest tests/test_exemples.py -k c01-centre : un seul exemple, avec la différence entre votre sortie et la sortie attendue.
.gitlab-ci.yml
La moulinette sur GitLab
Mettez .gitlab-ci.yml et requirements-ci.txt à la racine et poussez. À chaque push, GitLab lance la moulinette
(Build > Pipelines). Sur la branche principale, le résultat est envoyé à ce site : l'onglet
L'équipe coche tout seul les exercices validés.
Étapes
Settings > CI/CD > Variables : ajoutez SUIVI_URL = l'adresse de ce site.
Ajoutez SUIVI_JETON = le jeton donné par la personne qui héberge le site ; cochez Masked.
Poussez, puis ouvrez le pipeline : le job moulinette doit tourner.
Pourquoi ?
Seule la branche principale envoie son rapport : l'avancement de l'équipe, c'est ce qui est fusionné. Le jeton empêche n'importe qui d'envoyer un faux rapport.
Vérification
Après une fusion dans main, la fin du journal du job dit « rapport reçu », et l'onglet L'équipe montre le commit.
Partie C — premiers pas
essai_ursina.py (hors du dépôt)
Premier contact avec Ursina
Dans un fichier hors du dépôt, essayez Ursina avec le code ci-dessous, puis modifiez-le.
Étapes
Lancez-le : un cube orange qui tourne.
Changez sa couleur.
Ajoutez un deuxième cube à position=(2, 0, 0), puis un troisième à (0, 0, 2).
Ajoutez un sol : Entity(model="plane", scale=10, color=color.green, y=-0.5).
Notez dans le journal dans quel sens vont x, y et z à l'écran.
Information
Ursina appelle tout seul la fonction update() à chaque image : c'est la « boucle de jeu ». On ne l'écrit jamais soi-même.
Code fourni
Code fourni : à recopier tel quel
from ursina import Ursina, Entity, EditorCamera, color
app = Ursina()
cube = Entity(model="cube", color=color.orange)
EditorCamera() # clic droit pour tourner, molette pour zoomer
def update():
cube.rotation_y += 1
app.run()
assets/trous/trou5.png
Dessiner son premier trou
Dessinez un trou de 10 × 12 pixels dans Piskel (navigateur) ou LibreSprite.
Étapes
Importez la palette assets/palette.png.
Dessinez : des murs gris autour, de l'herbe, un départ blanc, un trou noir.
Exportez en PNG dans assets/trous/trou5.png.
Ajoutez une ligne à la liste de assets/trous/trous.json (ci-dessous).
Information
On pourra le voir au chapitre 01 (en texte) et au chapitre 03 (en 3D).
Warning(s)
Jamais de JPG : la compression change les couleurs, et une couleur inconnue est refusée.
Code fourni
Code fourni : à recopier tel quel
{"image": "trou5.png", "nom": "Mon premier trou", "par": 3}
Valider le chapitre
Les 4 PC ont tout fait ; chacun coche ses exercices. La date de début est dans l'onglet Planning.
Questions de soutenance
Pourquoi un environnement virtuel plutôt qu'installer les bibliothèques pour tout le PC ?
À quoi sert un test automatique, comparé à « je lance le jeu et je regarde » ?
Expliquez le rôle de __init__.py et de pythonpath = ..
Chapitre 01
Lire les trous
QuiPersonne 1Semaines2Difficulté★☆☆VersionV0
shared/course_loader.py
Lore
Les plans du parcours sont des images minuscules : un pixel, une case. Le vert, c'est l'herbe ; le noir, le trou ; le transparent, le vide. Votre mission : apprendre à la machine à lire ces plans sans jamais se tromper d'une case, et à crier clairement quand un plan est mal dessiné.
Objectif
Transformer un PNG en une grille Python (un Parcours) que tout le jeu utilisera : la physique, le bot, le serveur, le rendu. Ce fichier est le seul du projet à lire des PNG.
Cours · Le repère du jeu
image PNG (y vers le bas) grille du jeu (z vers le Nord)
y=0 # # # # # cz=4 # # # # # <- Nord
y=1 # . O . # cz=3 # . O . #
y=2 # . . . # ==> cz=2 # . . . #
y=3 # . S . # cz=1 # . S . #
y=4 # # # # # cz=0 # # # # # <- Sud
Dans l'image, y descend. Dans le jeu, z monte vers le Nord. Le pixel (x, y) donne donc la case
cx = x, cz = profondeur - 1 - y.
On range les types dans types[cz][cx] : d'abord la ligne, puis la colonne. La case (cx, cz) couvre,
dans le monde, x de cx à cx + 1 et z de cz à cz + 1.
Cours · Pillow en 4 lignes
from PIL import Image
image = Image.open("trou.png").convert("RGBA") # 4 nombres par pixel : rouge, vert, bleu, transparence
largeur, hauteur = image.size
r, g, b, a = image.getpixel((x, y)) # de 0 à 255 chacun ; a = 0 : transparent
Cours · Les dataclasses
Une @dataclass est une classe qui sert surtout à ranger des données : Python écrit le constructeur pour vous à partir
des champs annotés.
@dataclass
class Point:
x: float
y: float = 0.0 # valeur par défaut
p = Point(3.0) # Point(x=3.0, y=0.0)
print(p.x) # 3.0
Pour une liste par défaut, field(default_factory=list) : une liste neuve par objet.
Cours · Les exceptions
Une exception signale un problème qu'on ne peut pas régler sur place. On en crée une à soi en héritant d'Exception,
et on la lève avec raise :
class ErreurTruc(Exception):
'''Ce qui ne va pas.'''
raise ErreurTruc("explication") # arrête la fonction ; l'appelant peut l'attraper avec try / except
Pourquoi ?
Pourquoi un seul module lit les images ? Si demain les trous passent d'un PNG à un fichier texte, on change un fichier et rien d'autre : tout le reste du jeu ne connaît que le Parcours. Voir docs/01_architecture.md.
shared/course_loader.py — le Parcours
shared/course_loader.py
ErreurParcours
Lore
Un plan mal dessiné doit faire du bruit, pas planter en silence trois écrans plus loin.
Créez l'exception ErreurParcours. Elle ne fait rien de plus qu'une Exception : elle a juste son propre nom, pour qu'on puisse l'attraper elle et seulement elle.
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de shared/course_loader.py
"""Chargement d'un trou : UN PNG -> un Parcours. [difficulté : ★☆☆]
1 pixel = 1 case, la couleur donne le type (voir constants.PALETTE).
Le nom et le par de chaque trou sont dans assets/trous/trous.json :
[{"image": "trou1.png", "nom": "Tout droit", "par": 2}, ...]
L'ordre de cette liste est l'ordre des trous dans la partie.
Repère : la case (cx, cz) couvre x dans [cx, cx+1] et z dans [cz, cz+1].
cz = 0 est la ligne du BAS de l'image (le Sud).
Donc le pixel (x, y) de l'image donne la case cx = x, cz = hauteur_image - 1 - y.
"""
import json
from dataclasses import dataclass, field
from PIL import Image
from shared import constants as C
Information
Une classe vide en Python contient au moins une ligne : ici, sa docstring suffit (pas besoin de pass).
Prototype(s)
class ErreurParcours(Exception):
"""Parcours invalide. Le message dit quoi et où (« ... en x=12, y=7 »)."""
Code example(s)
from shared.course_loader import ErreurParcours
try:
raise ErreurParcours("il manque le trou")
except ErreurParcours as e:
print("attrapée :", e)
print(issubclass(ErreurParcours, Exception))
Output
attrapée : il manque le trou
True
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/course_loader.py
La dataclass Parcours
Écrivez la dataclass Parcours avec exactement les champs du prototype, dans cet ordre.
Information
types[cz][cx] est le type de la case (cx, cz) : une chaîne comme "herbe", "mur", "vide"... (les valeurs de C.PALETTE).
Danger(s)
passages: list = [] est refusé par Python dans une dataclass (une seule liste serait partagée par tous les parcours). Utilisez field(default_factory=list).
Prototype(s)
@dataclass
class Parcours:
nom: str
par: int
largeur: int
profondeur: int
types: list # types[cz][cx] -> "herbe", "mur", ...
depart: tuple # (cx, cz) de la case de départ
trou: tuple # (cx, cz) de la case du trou
passages: list = field(default_factory=list) # [(cx, cz), ...] repères du bot
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/course_loader.py — les couleurs
shared/course_loader.py
couleur_proche
Lore
Certains logiciels de dessin décalent une couleur d'une ou deux unités en l'enregistrant. Le lecteur de plans doit reconnaître « presque vert » comme du vert.
Renvoyez True si chacun des trois canaux de rgb est à au plus C.TOLERANCE_COULEUR du canal correspondant de reference.
Information
rgb et reference sont deux tuples (r, g, b). C.TOLERANCE_COULEUR vaut 10.
Hint(s)
Indice 1 L'écart
L'écart entre deux nombres, c'est abs(a - b) : la valeur absolue de la différence.
Indice 2 Trois canaux à la fois
zip(rgb, reference) donne les paires (r, r_ref), (g, g_ref), (b, b_ref). all(...) est vrai si tout est vrai.
Prototype(s)
def couleur_proche(rgb, reference):
"""Vrai si chacun des trois canaux de rgb est à au plus TOLERANCE_COULEUR de celui de reference."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/course_loader.py
couleur_vers_type
Reçoit la couleur d'un pixel (r, g, b, a) et sa position x, y dans l'image. Renvoie le type de case de cette couleur.
Étapes
Pixel transparent (a < 128) : "vide".
Sinon, pour chaque couleur de C.PALETTE : si couleur_proche, renvoyer son type.
Aucune trouvée : lever ErreurParcours avec exactement le message couleur inconnue (r, g, b) en x=…, y=….
Information
C.PALETTE est un dictionnaire {(r, g, b): type}. for reference, type_case in C.PALETTE.items(): donne chaque couple.
Hint(s)
Indice 1 Le message
f"couleur inconnue {(r, g, b)} en x={x}, y={y}" : un tuple dans une f-string s'affiche avec ses parenthèses.
Prototype(s)
def couleur_vers_type(rgba, x, y):
"""Couleur d'un pixel -> type de case. x, y = position dans l'image.
Transparent -> 'vide'. Couleur inconnue -> ErreurParcours avec la position.
"""
pass
herbe
herbe
rampe_N
vide
ErreurParcours: couleur inconnue (60, 200, 120) en x=12, y=7
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/course_loader.py — lire une image
shared/course_loader.py
grille
Renvoyez une liste de profondeur lignes, chaque ligne étant une liste de largeur fois valeur. Les lignes doivent être des listes distinctes.
Danger(s)
Le piège classique : [[valeur] * largeur] * profondeur crée une seule ligne, répétée. Modifier g[0][0] modifie alors toutes les lignes. Essayez dans la console : a = [[0] * 2] * 2; a[0][0] = 9; print(a).
Hint(s)
Indice 1 Une ligne neuve à chaque tour
Une boucle (ou une compréhension) qui crée [valeur] * largeur à chaque tour : [... for _ in range(profondeur)].
Prototype(s)
def grille(largeur, profondeur, valeur):
"""Une grille de profondeur lignes de largeur cases, toutes à valeur (des lignes DISTINCTES)."""
pass
Code example(s)
from shared.course_loader import grille
g = grille(3, 2, "")
print(g)
g[0][0] = "mur"
print(g)
print(grille(2, 3, 0))
def charger_trou(numero, dossier=C.DOSSIER_TROUS):
"""Charge le trou n° numero (1 = le premier de trous.json)."""
pass
Code example(s)
from shared.course_loader import charger_trou, ErreurParcours
p = charger_trou(3)
print(p.nom, p.par, p.depart, p.trou)
for n in (0, 42):
try:
charger_trou(n)
except ErreurParcours:
print(n, "-> ErreurParcours")
Avant les grandes machines, l'atelier a besoin d'outils : savoir quelles balles existent, donner un nom à un score, retenir les préférences de chaque joueur. Petits, mais tout le monde s'en sert : s'ils sont faux, tout est faux.
Objectif
Trois petits modules en Python pur. Priorité : chemin_balle et reglages.charger d'abord, la personne 2 en a besoin pour le chapitre 03.
Cours · pathlib
from pathlib import Path
dossier = Path("assets/balles")
chemin = dossier / "rayee.png" # le / construit un chemin
chemin.exists() # True si le fichier existe
chemin.stem # "rayee" : le nom sans extension
list(dossier.glob("*.png")) # tous les PNG du dossier
Cours · Le golf en 30 secondes
Chaque trou a un par : le nombre de coups d'un bon joueur. Le nom du score dépend de l'écart coups - par :
coups - par
-3
-2
-1
0
+1
+2
+3
+4...
nom
Albatros
Aigle
Birdie
Par
Bogey
Double bogey
Triple bogey
« +4 »...
Un trou fini en 1 coup est toujours un « Trou en un », quel que soit le par.
Cours · Trier avec key
sorted(liste, key=f) trie selon la valeur de f(élément). Exemple :
sorted(joueurs, key=lambda j: sum(j["coups"])) trie par total de coups. Le tri de Python est stable :
deux éléments égaux gardent leur ordre de départ.
Pourquoi ?
Pourquoi les réglages ne doivent-ils jamais faire planter le jeu ? Un reglages.json abîmé (coupure de courant, édition à la main) ne doit pas empêcher de jouer : on retombe sur les valeurs par défaut.
shared/balles.py — les balles
shared/balles.py
lister_balles
Lore
Une balle, c'est un PNG dans assets/balles/. Ajouter une balle au jeu = déposer un fichier, rien d'autre.
Renvoyez les noms des balles disponibles (le nom du fichier sans .png), triés, avec la balle PAR_DEFAUT (« blanche ») mise en premier si elle existe.
Étapes
Si dossier est None, prendre C.DOSSIER_BALLES.
Les noms : le .stem de chaque fichier de dossier.glob("*.png"), triés.
Si PAR_DEFAUT est dans la liste : l'enlever puis l'insérer à l'indice 0.
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de shared/balles.py
"""Balles personnalisées : une texture PNG par balle dans assets/balles/. [difficulté : ★☆☆]
Python pur. Une texture de balle est une image 2:1 (par exemple 64 x 32)
enroulée autour de la sphère : la largeur fait le tour (l'équateur), la
hauteur va du pôle Nord (en haut) au pôle Sud (en bas).
Ajouter une balle = déposer un PNG dans assets/balles/, c'est tout.
"""
import math
from shared import constants as C
PAR_DEFAUT = "blanche"
Prototype(s)
def lister_balles(dossier=None):
"""Noms des balles disponibles (le nom du fichier sans .png), triés, la balle par défaut d'abord."""
pass
Code example(s)
from shared.balles import lister_balles
print(lister_balles()[0])
d = pathlib.Path(tempfile.mkdtemp())
for nom in ("zebre", "blanche", "abeille", "notes"):
(d / (nom + (".txt" if nom == "notes" else ".png"))).write_bytes(b"")
print(lister_balles(d))
Output
blanche
['blanche', 'abeille', 'zebre']
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/balles.py
chemin_balle
Renvoyez le chemin du PNG de la balle nom. S'il n'existe pas, celui de la balle par défaut.
Information
Un réglage peut contenir une balle qui a été supprimée depuis : le jeu ne doit pas planter pour ça.
Prototype(s)
def chemin_balle(nom, dossier=None):
"""Chemin du PNG d'une balle ; la balle par défaut si ce nom n'existe pas."""
pass
Code example(s)
from shared.balles import chemin_balle
print(chemin_balle("rayee").name)
print(chemin_balle("licorne").name)
print(chemin_balle("blanche").parent.name)
Output
rayee.png
blanche.png
balles
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/scores.py — les scores
shared/scores.py
nom_score
Renvoyez le nom du score d'un trou fini en coups coups, pour un trou de par par.
Étapes
1 coup : "Trou en un", toujours.
Sinon, l'écart coups - par : s'il est dans NOMS (fourni), son nom.
Au-delà : "+4", "+5"... (avec le +) ; en dessous de -3 : "-4"...
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de shared/scores.py
"""Scores spéciaux du golf et bilan d'une partie. [difficulté : ★☆☆]
Python pur. Le nom d'un score dépend de l'écart avec le par du trou :
coups - par = -3 albatros, -2 aigle, -1 birdie, 0 par, +1 bogey...
Et un trou fini en 1 coup est toujours un « trou en un ».
Le classement, lui, se fait seulement au total des coups.
"""
NOMS = {-3: "Albatros", -2: "Aigle", -1: "Birdie", 0: "Par",
1: "Bogey", 2: "Double bogey", 3: "Triple bogey"}
SPECIAUX = ["Trou en un", "Albatros", "Aigle", "Birdie"]
PLURIELS = {"Trou en un": "trous en un", "Albatros": "albatros", "Aigle": "aigles", "Birdie": "birdies"}
Prototype(s)
def nom_score(coups, par):
"""Nom du score d'un trou : nom_score(1, 3) -> "Trou en un", nom_score(4, 3) -> "Bogey"."""
pass
Code example(s)
from shared.scores import nom_score
for coups, par in [(1, 3), (2, 5), (3, 5), (2, 3), (3, 3), (4, 3), (5, 3), (6, 3), (8, 3), (1, 1), (2, 7)]:
print(coups, par, nom_score(coups, par))
Output
1 3 Trou en un
2 5 Albatros
3 5 Aigle
2 3 Birdie
3 3 Par
4 3 Bogey
5 3 Double bogey
6 3 Triple bogey
8 3 +5
1 1 Trou en un
2 7 -5
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/scores.py
bilan
coups et pars : deux listes de même longueur, un élément par trou. Comptez combien de fois chaque nom de SPECIAUX apparaît. Renvoyez un dictionnaire dans l'ordre de SPECIAUX, sans les noms à 0.
Étapes
Un dictionnaire brouillon compte = {}.
Pour chaque paire (c, p) de zip(coups, pars) : le nom du score ; s'il est dans SPECIAUX, ajouter 1 à son compte.
Construire le résultat en parcourant SPECIAUX (pour l'ordre), en gardant les noms présents dans compte.
Information
Un dictionnaire Python garde l'ordre dans lequel on a ajouté les clés : c'est ce qui permet l'étape 3.
Hint(s)
Indice 1 Compter
compte[n] = compte.get(n, 0) + 1 : get donne 0 si la clé n'existe pas encore.
Prototype(s)
def bilan(coups, pars):
"""Compte les scores spéciaux d'un joueur sur toute la partie.
coups = [3, 1, 2], pars = [3, 3, 3] -> {"Trou en un": 1, "Birdie": 1} (le par ne compte pas)
Renvoie seulement les noms de SPECIAUX, dans cet ordre, avec un compte > 0.
"""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/scores.py
classement
Triez les joueurs par total de coups (du meilleur au pire) et ajoutez à chacun "total" et "rang". Deux ex aequo ont le même rang, et le suivant saute : totaux 4, 4, 6 → rangs 1, 1, 3.
Étapes
tries = sorted(joueurs, key=...) par somme des coups.
Parcourir tries avec enumerate en gardant le total précédent.
Si le total change : le rang devient i + 1. Sinon il reste le même.
Renvoyer tries.
Prototype(s)
def classement(joueurs):
"""joueurs = [{"nom": ..., "coups": [...]}, ...] -> la même liste triée par total de coups.
Chaque joueur reçoit un champ "total" et un champ "rang" (ex aequo = même rang).
"""
pass
Code example(s)
from shared.scores import classement
for j in classement([{"nom": "Alice", "coups": [3, 3]}, {"nom": "Bob", "coups": [2, 2]},
{"nom": "Chloé", "coups": [3, 1]}, {"nom": "Dan", "coups": [4, 4]}]):
print(j["rang"], j["nom"], j["total"])
Output
1 Bob 4
1 Chloé 4
3 Alice 6
4 Dan 8
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py — les réglages
client/reglages.py
option
Renvoyez le dictionnaire de OPTIONS dont la clé "cle" vaut cle. Aucun : lever KeyError(cle).
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de client/reglages.py
"""Réglages du joueur, gardés dans reglages.json. [difficulté : ★☆☆]
Python pur, sans Ursina. Le menu (client/menu.py) affiche OPTIONS et
appelle changer() quand on clique sur - ou + ; on sauvegarde à chaque
changement, donc on retrouve ses réglages au prochain lancement.
"""
import json
from shared import constants as C
from shared.balles import PAR_DEFAUT, lister_balles
FICHIER = C.RACINE / "reglages.json"
DEFAUTS = {
"nom": "Joueur",
"balle": PAR_DEFAUT,
"ip": "192.168.1.",
"bots": 1,
"niveau": "moyen",
"rendu": "texture",
"trou": 1,
"sensibilite": 5,
"plein_ecran": False,
"afficher_fps": False,
}
OPTIONS = [
{"cle": "nom", "libelle": "Pseudo", "type": "texte"},
{"cle": "balle", "libelle": "Ma balle", "type": "choix", "choix": None}, # None : les PNG de assets/balles
{"cle": "ip", "libelle": "IP de l'hôte", "type": "texte"},
{"cle": "bots", "libelle": "Bots (solo / hôte)", "type": "nombre", "mini": 0, "maxi": 3},
{"cle": "niveau", "libelle": "Niveau des bots", "type": "choix", "choix": list(C.NIVEAUX_BOT)},
{"cle": "trou", "libelle": "Trou (entraînement, visite)", "type": "nombre", "mini": 1, "maxi": 9},
{"cle": "rendu", "libelle": "Rendu", "type": "choix", "choix": ["texture", "cubes"]},
{"cle": "sensibilite", "libelle": "Sensibilité caméra", "type": "nombre", "mini": 1, "maxi": 10},
{"cle": "plein_ecran", "libelle": "Plein écran", "type": "oui_non"},
{"cle": "afficher_fps", "libelle": "Images par seconde", "type": "oui_non"},
]
Information
DEFAUTS (les valeurs de départ) et OPTIONS (ce que le menu affichera) sont fournis dans le début du fichier.
Prototype(s)
def option(cle):
"""La description (un dict de OPTIONS) de l'option qui a cette clé. KeyError si elle n'existe pas."""
pass
Code example(s)
from client import reglages as R
print(R.option("rendu"))
print(R.option("bots")["maxi"])
try:
R.option("couleur")
except KeyError as e:
print("KeyError", e)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
choix_possibles
Renvoyez la liste des valeurs possibles d'une option de type "choix" : o["choix"], sauf s'il vaut None (la balle) : alors lister_balles(), ou [PAR_DEFAUT] si elle est vide.
Prototype(s)
def choix_possibles(o):
"""La liste des valeurs d'une option « choix ». Pour la balle : les fichiers de assets/balles/."""
pass
Code example(s)
from client import reglages as R
print(R.choix_possibles(R.option("niveau")))
print(R.choix_possibles(R.option("rendu")))
print(R.choix_possibles(R.option("balle"))[0])
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
charger
Renvoyez les réglages lus dans fichier (FICHIER si None), complétés par les valeurs par défaut. Un fichier absent ou abîmé donne les valeurs par défaut, sans planter.
Étapes
Partir d'une copie : reglages = dict(DEFAUTS).
Dans un try : lire le JSON du fichier.
Pour chaque clé de DEFAUTS présente dans ce qui a été lu : remplacer la valeur (les clés inconnues sont ignorées).
except (OSError, ValueError): ne rien faire.
Renvoyer reglages.
Danger(s)
reglages = DEFAUTS ne copie pas : les deux noms désignent le même dictionnaire, et modifier l'un modifie les valeurs par défaut pour toute la suite du programme.
Prototype(s)
def charger(fichier=None):
"""Les réglages du fichier (reglages.json par défaut), complétés par les valeurs par défaut.
Fichier absent ou abîmé : les valeurs par défaut (le jeu ne doit jamais planter pour ça).
"""
pass
Code example(s)
from client import reglages as R
d = pathlib.Path(tempfile.mkdtemp())
print(R.charger(d / "absent.json")["nom"])
(d / "vieux.json").write_text('{"nom": "Léo", "inconnu": 1}', encoding="utf-8")
r = R.charger(d / "vieux.json")
print(r["nom"], r["rendu"], "inconnu" in r)
(d / "abime.json").write_text("{pas du json", encoding="utf-8")
print(R.charger(d / "abime.json") == R.DEFAUTS)
Output
Joueur
Léo texture False
True
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
sauver
Écrivez les réglages dans fichier (FICHIER si None), en JSON lisible : ensure_ascii=False (les accents restent des accents), indent=2.
Prototype(s)
def sauver(reglages, fichier=None):
"""Écrit les réglages dans le fichier (reglages.json par défaut), en JSON lisible."""
pass
Code example(s)
from client import reglages as R
f = pathlib.Path(tempfile.mkdtemp()) / "r.json"
r = dict(R.DEFAUTS, nom="Zoé")
R.sauver(r, f)
print(f.read_text(encoding="utf-8").splitlines()[:3])
print(R.charger(f) == r)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
voisin
Renvoyez l'élément qui suit (sens = +1) ou précède (sens = -1) valeur dans liste, en boucle. Une valeur absente de la liste compte comme le premier élément.
Hint(s)
Indice 1 La position
liste.index(v) donne la position de v (erreur si absente : testez v in liste avant).
Indice 2 En boucle
(i + sens) % len(liste).
Prototype(s)
def voisin(liste, valeur, sens):
"""L'élément après (sens=+1) ou avant (sens=-1) valeur dans liste, en boucle.
Une valeur absente de la liste compte comme le premier élément.
"""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
changer
Changez l'option cle d'un cran (sens : +1 pour le bouton +, -1 pour le bouton -). Modifiez reglages[cle]et renvoyez la nouvelle valeur.
Étapes
o = option(cle), v = reglages[cle].
Type "nombre" : v + sens, sans descendre sous o["mini"] ni dépasser le maximum. Le maximum est maxi s'il est donné, sinon o["maxi"].
Type "choix" : voisin(choix_possibles(o), v, sens).
Type "oui_non" : not v. Type "texte" : rien.
Ranger v dans reglages[cle] et la renvoyer.
Prototype(s)
def changer(reglages, cle, sens, maxi=None):
"""Change une option d'un cran : sens = +1 (bouton +) ou -1 (bouton -).
maxi remplace le maximum de l'option (ex. le nombre de trous). Renvoie la nouvelle valeur.
"""
pass
Code example(s)
from client import reglages as R
r = dict(R.DEFAUTS)
print([R.changer(r, "bots", +1) for _ in range(4)])
print(R.changer(r, "rendu", +1), R.changer(r, "rendu", +1))
print(R.changer(r, "trou", +1, maxi=2), R.changer(r, "trou", +1, maxi=2))
print(R.changer(r, "plein_ecran", +1), r["plein_ecran"])
print(R.changer(r, "nom", +1))
Output
[2, 3, 3, 3]
cubes texture
2 2
True True
Joueur
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
texte_valeur
Renvoyez la valeur de l'option telle que le menu l'affiche : "oui" / "non" pour une option "oui_non", sinon str(valeur).
Prototype(s)
def texte_valeur(reglages, cle):
"""La valeur telle qu'on l'affiche dans le menu."""
pass
Code example(s)
from client import reglages as R
r = dict(R.DEFAUTS)
print(R.texte_valeur(r, "plein_ecran"), R.texte_valeur(r, "bots"), R.texte_valeur(r, "nom"))
Output
non 1 Joueur
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/reglages.py
resume_bots
Le résumé des bots pour les boutons du menu : "sans bot", "1 bot moyen", "3 bots fort" (un s à partir de 2).
Prototype(s)
def resume_bots(reglages):
"""« sans bot », « 1 bot moyen », « 3 bots fort » : pour le texte des boutons du menu."""
pass
Code example(s)
from client import reglages as R
for n, niveau in [(0, "fort"), (1, "moyen"), (3, "fort")]:
print(R.resume_bots(dict(R.DEFAUTS, bots=n, niveau=niveau)))
Output
sans bot
1 bot moyen
3 bots fort
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
Valider le chapitre
python tools/progression.py 2 : tout validé (roulement viendra au chapitre 13).
Pour aller plus loin (facultatif)
Ajoutez un « Condor » (-4) dans NOMS. Dessinez une balle (PNG 64 × 32) dans assets/balles/.
Questions de soutenance
Pourquoi le jeu ne doit-il jamais planter si reglages.json est abîmé ?
Pourquoi le classement se fait-il au total des coups, et jamais au temps ?
Le plan est lu. Il est temps de le voir : des cubes posés sur le néant, un drapeau rouge au bout, et un œil qui tourne autour. Rien ne bouge encore, mais pour la première fois, le parcours existe.
Objectif
python jouer.py --visite 1 --cubes ouvre une fenêtre, affiche le trou 1 en cubes, la caméra tourne avec les flèches, N et P changent de trou. C'est la V0. Il vous faut : chapitre 01 (charger_trou), chapitre 02 (chemin_balle, reglages.charger). En attendant, commencez par les fonctions pures.
Cours · Entity
Dans Ursina, tout ce qui existe dans la scène est une Entity :
model : la forme ("cube", "sphere", "quad" pour un carré plat) ;
position : le centre de l'objet (x, y, z) ; scale : sa taille sur chaque axe ;
parent : l'entité suit son parent, et destroy(parent) détruit aussi tous ses enfants ;
camera.ui est le parent spécial des éléments 2D collés à l'écran.
Cours · update et input
Ursina appelle tout seul, sur chaque entité :
update() à chaque image ;
input(touche) à chaque touche ou clic.
time.dt est la durée de la dernière image (environ 1/60 s). Une vitesse multipliée par time.dt donne le même
mouvement à 30 ou à 144 images par seconde.
Cours · La caméra en orbite
caméra
* distance : la longueur du trait
/| tangage : l'angle avec le sol (vu de côté)
/ | angle : où l'on regarde (vu de dessus) ;
/ | 0 = Est (+x), 90 = Nord (+z)
cible *---+
Au sol, la caméra est à distance × cos(tangage) de la cible, derrière elle ; en hauteur, à
distance × sin(tangage). math.cos et math.sin veulent des radians : math.radians(degres).
Pourquoi ?
Pourquoi deux rendus (cubes et texturé) ? La version cubes est simple : un cube par case. La version texturée (fournie) fabrique un seul objet 3D pour tout le parcours : beaucoup plus rapide, mais c'est de la géométrie avancée. Voir docs/04_rendu.md.
Warning(s)
Le piège du chapitre. Comme Ursina appelle tout seul update() et input(), une de ces méthodes qui
n'existe pas encore, ou qui lève une erreur, fait planter le jeu dès la première image. Ce chapitre écrit donc certaines méthodes en
version provisoire, complétée plus tard. Chaque exercice le dit.
client/world_builder.py — le décor en cubes
client/world_builder.py
Lire hauteurs_dessus (fournie)
Le fichier est fourni, sauf trois fonctions à écrire. Commencez par lire hauteurs_dessus(parcours, cx, cz) : la hauteur des 4 coins du dessus d'une case, ou None pour le vide. Lancez l'exemple et expliquez chaque ligne de la sortie.
Code example(s)
from client.world_builder import hauteurs_dessus
p = parcours_ascii([".h#_^"])
for cx in range(5):
print(p.type_case(cx, 0), hauteurs_dessus(p, cx, 0))
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/world_builder.py
cube_case
Lore
Avant de poser un cube, il faut savoir où, de quelle taille, de quelle couleur. Tout ça se calcule sans fenêtre.
Renvoyez le cube de la case (cx, cz) sous la forme (rgb, position, taille), ou None pour le vide. Le cube va du fond (C.FOND) jusqu'au dessus de la case.
haut = la moyenne des 4 coins (une rampe se retrouve à mi-hauteur).
rgb = la couleur du type dans COULEURS_CUBES, ou orange (255, 150, 0) s'il n'y est pas (les rampes).
position = (cx + 0.5, milieu en y, cz + 0.5) ; taille = (1, hauteur du cube, 1).
Hint(s)
Indice 1 Taille et centre
Un cube de hauteur h est centré sur sa position : il va de y - h/2 à y + h/2. Pour aller de C.FOND à haut : hauteur haut - C.FOND, centre (haut + C.FOND) / 2.
Prototype(s)
def cube_case(parcours, cx, cz):
"""Le cube de la case (cx, cz) : (couleur rgb, position, taille), ou None pour le vide.
Le cube va du fond (C.FOND) jusqu'au dessus de la case ; une rampe : jusqu'à mi-hauteur, en orange.
"""
pass
Code example(s)
from client.world_builder import cube_case
p = parcours_ascii([".h#_^"])
for cx in range(5):
print(cube_case(p, cx, 0))
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/world_builder.py
creer_monde_cubes
Créez le décor : une Entity() vide, monde, et un cube enfant de monde par case non vide. Ajoutez le décor du trou et renvoyez monde.
Étapes
from ursina import Entity, colordans la fonction (le reste du fichier doit rester importable sans Ursina).
monde = Entity().
Double boucle sur cz et cx ; cube_case ; si ce n'est pas None : Entity(parent=monde, model="cube", color=color.rgb32(*rgb), position=..., scale=...).
decor_trou(parcours, monde) (exercice suivant), puis return monde.
Information
color.rgb32(*rgb) : l'étoile « déballe » le tuple, comme si on écrivait color.rgb32(r, g, b).
Pourquoi ?
Pourquoi un seul parent ? Pour changer de trou, un seul destroy(monde) efface tout le décor.
Prototype(s)
def creer_monde_cubes(parcours):
"""Un cube de couleur par case (voir cube_case), enfants d'une seule Entity, et le décor du trou."""
pass
client/world_builder.py
decor_trou
Ajoutez au décor (enfants de monde) le trou et son drapeau, au centre de la case du trou.
Étapes
Le trou : model="quad" noir, couché (rotation_x=90), à y = 0.005 (juste au-dessus de l'herbe), côté 2 × C.TROU_RAYON.
Le mât : un cube blanc de 0,04 × 1,5 × 0,04, posé sur le sol (centre à y = 0.75).
Le drapeau : un cube rouge de 0,4 × 0,26 × 0,03, accroché en haut du mât, décalé de 0,22 vers l'Est.
Prototype(s)
def decor_trou(parcours, monde):
"""Le trou (carré noir) et son drapeau."""
pass
Vérification
Au prochain exercice qui ouvre une fenêtre : le trou est noir, le drapeau flotte à côté du mât.
client/camera_aim.py — la caméra, en Python pur
client/camera_aim.py
direction
Renvoyez le vecteur unitaire (dx, dz) d'un angle donné en degrés : (cos a, sin a), avec a en radians. 0° = Est (+x), 90° = Nord (+z).
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de client/camera_aim.py
"""Caméra orbitale, visée et jauge de puissance.
Partie Python pur (testable) : direction, position_camera, jauge, lisser,
distance_libre. [difficulté : ★★☆]
Partie Ursina : la classe Visee, qui lit le clavier et la souris.
Commandes :
clic droit maintenu + souris, ou flèches / Q D tourner autour de la balle
Z S monter / descendre la caméra
molette zoom
ESPACE ou clic gauche maintenu charger la jauge, relâcher pour tirer
"""
import math
PERIODE_JAUGE = 1.6 # s pour un aller-retour 0 -> 1 -> 0
TANGAGE_MIN, TANGAGE_MAX = 8.0, 80.0
DISTANCE_MIN, DISTANCE_MAX = 2.5, 30.0
SUIVI = 10.0 # plus grand = la caméra rattrape la balle plus vite
try:
from ursina import Entity, camera, color, held_keys, mouse, time
except ImportError: # permet d'importer la partie pure sans Ursina
Entity = object
Prototype(s)
def direction(angle_deg):
"""Vecteur unitaire (dx, dz) d'un angle de tir (0 = Est, 90 = Nord)."""
pass
Code example(s)
from client.camera_aim import direction
for angle in (0, 90, 180, 45):
dx, dz = direction(angle)
print(angle, round(dx, 3) + 0.0, round(dz, 3) + 0.0)
Output
0 1.0 0.0
90 0.0 1.0
180 -1.0 0.0
45 0.707 0.707
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py
position_camera
Renvoyez la position (x, y, z) de la caméra qui regarde cible dans la direction angle_deg, à distance de la cible, avec un tangage de tangage_deg (voir le cours).
Étapes
dx, dz = direction(angle_deg).
horizontal = distance × cos(tangage) (tangage en radians).
x : cible[0] - dx × horizontal (la caméra est derrière) ; z : pareil avec dz.
y : cible[1] + distance × sin(tangage).
Prototype(s)
def position_camera(cible, angle_deg, tangage_deg, distance):
"""Position (x, y, z) de la caméra : derrière la cible, dans l'axe du tir, en hauteur."""
pass
Code example(s)
from client.camera_aim import position_camera
for cible, angle, tangage, d in [((0, 0, 0), 90, 30, 10), ((5, 1, 5), 0, 0, 4), ((0, 0, 0), 180, 90, 2)]:
print([round(v, 3) + 0.0 for v in position_camera(cible, angle, tangage, d)])
Output
[0.0, 5.0, -8.66]
[1.0, 1.0, 5.0]
[0.0, 2.0, 0.0]
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py
borner
Renvoyez v ramené entre mini et maxi.
Hint(s)
Indice 1 En une ligne
min empêche de dépasser le haut, max empêche de passer sous le bas.
Prototype(s)
def borner(v, mini, maxi):
"""v ramené entre mini et maxi."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py — la classe Visee (version V0)
client/camera_aim.py
Visee.__init__ (fourni, à lire)
Recopiez ce constructeur sous les fonctions pures. Il déclare tout l'état de la visée : expliquez chaque attribut à un coéquipier (à quoi il sert, qui le change).
Code fourni
Code fourni : à recopier tel quel
class Visee(Entity):
...
def __init__(self, cible, sur_tir=None, peut_tirer=lambda: False, hauteur=None, **kwargs):
super().__init__(**kwargs)
self.cible, self.sur_tir, self.peut_tirer, self.hauteur = cible, sur_tir, peut_tirer, hauteur
self.angle, self.tangage, self.distance = 90.0, 38.0, 6.0
self.sensibilite = 5 # 1 à 10, réglable dans le menu
self.vue = None # (x, y, z) regardé, lissé
self.tangage_vu, self.distance_vue = self.tangage, self.distance
self.charge = None # temps d'appui, ou None
self.puissance = 0.0
self.fleche = Entity(model="cube", color=color.rgba32(255, 255, 255, 170),
scale=(0.05, 0.02, 1.4), enabled=False)
client/camera_aim.py
Visee.tourner
Faites tourner la visée avec le clavier et la souris (version complète).
Étapes
k = self.sensibilite / 5 (5 = vitesse normale).
Gauche / droite : held_keys["right arrow"] vaut 1 si la touche est enfoncée, 0 sinon. Droite (et D) moins gauche (et Q, A) donne -1, 0 ou 1 : self.angle -= ça × 90 × k × dt.
Haut / bas : Z (et W) moins S, × 50 × k × dt, ajouté à self.tangage.
Clic droit maintenu (mouse.right) : mouse.velocity[0] × 300 × k enlevé à l'angle, mouse.velocity[1] × 200 × k ajouté au tangage.
Enfin : tangage borné entre TANGAGE_MIN et TANGAGE_MAX.
Information
Pourquoi -= pour tourner à droite ? Vu de dessus, les angles montent dans le sens inverse des aiguilles d'une montre : tourner à droite fait baisser l'angle.
Prototype(s)
class Visee(Entity):
...
def tourner(self, dt):
"""Clavier et souris -> angle, tangage."""
pass
client/camera_aim.py
Visee.placer_camera (provisoire)
Version provisoire (la vraie au chapitre 05) : placez camera.position avec position_camera autour de self.cible(), puis camera.look_at(cible).
Information
self.cible est une fonction donnée au constructeur : on l'appelle pour savoir où regarder.
Prototype(s)
class Visee(Entity):
...
def placer_camera(self, dt):
"""Place la caméra derrière la cible (lissée), en montant si un mur la cache,
et la fait regarder la cible."""
pass
client/camera_aim.py
Visee.update et placer_fleche (provisoires)
Deux méthodes provisoires d'une ou deux lignes :
Étapes
placer_fleche() : self.fleche.enabled = False (pas encore de tir).
update() : self.tourner(time.dt) puis self.placer_camera(time.dt).
Prototype(s)
class Visee(Entity):
...
def placer_fleche(self):
"""Montre la flèche de visée devant la balle quand on peut tirer (plus longue quand on charge)."""
pass
class Visee(Entity):
...
def update(self):
"""À chaque image : tourner, placer la caméra et la flèche, faire avancer la jauge."""
pass
client/camera_aim.py
Visee.input (provisoire) : le zoom
Version provisoire : "scroll up" multiplie self.distance par 0,9 (on se rapproche), "scroll down" par 1,1. Bornez entre DISTANCE_MIN et DISTANCE_MAX.
Hud.__init__ est fourni (placer du texte à l'écran, c'est surtout tâtonner sur des coordonnées). Écrivez Hud.temps(secondes) en version provisoire : si secondes est None, vider self.chrono.text. La suite au chapitre 13.
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de client/hud.py
"""Affichage 2D : trou en cours, coups, jauge, messages, scores, classement.
Partie Python pur (testable) : les textes. [difficulté : ★☆☆]
Partie Ursina : la classe Hud (des Text et des quads dans camera.ui).
"""
from shared.scores import classement # noqa: E402
try:
from ursina import Entity, Text, camera, color, invoke
except ImportError:
Entity = object
Information
Le début de fichier importe classement de shared/scores.py : il faut que le chapitre 02 l'ait écrit.
Prototype(s)
class Hud(Entity):
...
def temps(self, secondes):
"""Chrono en haut à droite ; rouge sous 10 s. None pour le cacher."""
pass
Créez client/main.py avec ce début, puis recopiez le constructeur de Jeu (classe ci-dessous). Il ne fait que déclarer l'état d'une partie : lisez chaque attribut, vous les utiliserez tous.
Information
Jeu.__init__ complet est dans la carte suivante. Il utilise des méthodes que vous écrivez dans ce chapitre.
Code fourni
Code fourni : à recopier tel quel
"""Cubogolf - le client : menu, connexion, boucle de jeu."""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from ursina import Entity, Sky, Text, Ursina, color, destroy, invoke, time, window # noqa: E402
from client import reglages as R # noqa: E402
from client.camera_aim import Visee # noqa: E402
from client.hud import Hud # noqa: E402
from client.textures import texture_balle # noqa: E402
from client.world_builder import creer_monde, creer_monde_cubes # noqa: E402
from shared import constants as C # noqa: E402
from shared.course_loader import charger_trou, lister_trous, numero_voisin # noqa: E402
# ---------- puis la classe Jeu ----------
class Jeu(Entity):
"""Une partie. mode : 'reseau', 'entrainement' ou 'visite'."""
# (recopiez ici Jeu.__init__ : voir le code sous ce bloc)
client/main.py
Jeu.__init__ (fourni, à lire)
Le constructeur de la partie. Expliquez à un coéquipier : pourquoi la balle 3D (ent_balle) existe dès le début, même cachée ? Pourquoi passe-t-on des méthodes (self.cible_camera...) à la Visee ?
Code fourni
Code fourni : à recopier tel quel
class Jeu(Entity):
...
def __init__(self, mode, numero=1, reseau=None, cubes=False, sensibilite=5, balle="blanche"):
super().__init__()
self.mode, self.reseau, self.cubes = mode, reseau, cubes
self.total = len(lister_trous())
self.numero = numero
self.parcours = self.monde = self.balle = None
self.mon_id = None
self.fini = False
self.en_pause = False
self.accu = 0.0 # temps pas encore simulé (< 1 pas)
self.avant = (0, 0, 0) # position au pas précédent (pour interpoler)
self.chrono_envoi = 0.0
self.autres = {} # id -> Entity des balles des autres
self.cibles_autres = {} # id -> dernière position reçue
self.derniers_scores, self.derniers_pars = {}, []
self.premier = None # nom du premier à avoir fini le trou (annoncé)
self.hud = Hud()
self.ent_balle = Entity(model="sphere", texture=texture_balle(balle),
scale=C.BALLE_RAYON * 2, enabled=False)
self.visee = Visee(cible=self.cible_camera, sur_tir=self.tirer,
peut_tirer=self.peut_tirer, hauteur=self.hauteur_terrain)
self.visee.sensibilite = sensibilite
if mode == "visite":
self.visee.tangage, self.visee.distance = 50, 22
if mode == "reseau":
self.hud.montrer_panneau("Connexion au serveur...")
else:
self.charger(numero)
client/main.py
Jeu.charger (version visite)
Chargez le trou numero et construisez son décor. Pour l'instant, seulement ce qui sert à la visite (la balle viendra au chapitre 05).
Étapes
S'il y a déjà un décor (self.monde) : le détruire.
self.numero = numero ; self.parcours = charger_trou(numero).
self.monde = creer_monde_cubes(...) si self.cubes, sinon creer_monde(...).
Remettre à zéro : fini = False, accu = 0.0, premier = None ; self.hud.temps(None).
En visite : self.hud.haut.text = f"Visite - trou {numero} : {nom} (N / P : changer de trou)", puis return.
Prototype(s)
class Jeu(Entity):
...
def charger(self, numero):
"""Charge le trou numero : construit le décor (cubes ou texturé), remet la balle au départ
(sauf en visite), met à jour le HUD et annonce le nom du trou."""
pass
client/main.py
Jeu.cible_camera
Ce que la caméra regarde : la balle dessinée s'il y en a une (self.ent_balle.position, en tuple), sinon le milieu du parcours à hauteur 1, sinon (0, 0, 0).
Prototype(s)
class Jeu(Entity):
...
def cible_camera(self):
"""Ce que la caméra regarde : la balle dessinée, ou le milieu du parcours en visite."""
pass
client/main.py
Jeu : trois méthodes provisoires
Ursina et la Visee les appellent dès la première image : elles doivent exister, même vides.
Étapes
hauteur_terrain(x, z) : return None (pas encore de collision de caméra).
peut_tirer() : return False.
update() : pass.
Prototype(s)
class Jeu(Entity):
...
def hauteur_terrain(self, x, z):
"""La hauteur du terrain en (x, z) pour la caméra (None en visite : pas de collision)."""
pass
class Jeu(Entity):
...
def peut_tirer(self):
"""Vrai si on a une balle, à l'arrêt, et que le trou n'est pas fini."""
pass
class Jeu(Entity):
...
def update(self):
"""À chaque image : lire les messages du réseau, faire glisser et rouler les balles des autres,
faire avancer la physique (sauf en pause hors ligne), afficher la jauge."""
pass
client/main.py
Jeu.input : N et P en visite
En visite, la touche "n" charge le trou suivant et "p" le précédent, en boucle (numero_voisin, chapitre 01).
Prototype(s)
class Jeu(Entity):
...
def input(self, touche):
"""Touches de la partie : Tab (scores), Entrée (prêt), N / P (visite), R (entraînement)."""
pass
client/main.py
Le contrôleur Cubogolf et main (fournis)
Cubogolf sait quel écran est affiché et lance les parties ; main() lit la ligne de commande (--visite 1 --cubes...) et ouvre la fenêtre. Recopiez-les à la fin du fichier.
Code fourni
Code fourni : à recopier tel quel
class Cubogolf(Entity):
...
def __init__(self):
super().__init__()
self.reglages = R.charger()
self.jeu = None
self.ecran = None # l'écran de menu affiché, ou None
self.serveur = None # processus du serveur local, ou None
self.forcer_cubes = False # --cubes en ligne de commande (jamais sauvegardé)
self.appliquer()
def main():
p = argparse.ArgumentParser(description="Cubogolf (sans option : le menu)")
g = p.add_mutually_exclusive_group()
g.add_argument("--solo", action="store_true")
g.add_argument("--heberger", action="store_true")
g.add_argument("--rejoindre", metavar="IP")
g.add_argument("--entrainement", type=int, metavar="TROU")
g.add_argument("--visite", type=int, metavar="TROU")
p.add_argument("--cubes", action="store_true", help="rendu simple, un cube par case")
o = p.parse_args()
app = Ursina(title="Cubogolf", borderless=False, development_mode=False)
window.color = color.rgb32(135, 190, 235)
Sky(color=color.rgb32(150, 200, 240))
jeu = Cubogolf()
jeu.forcer_cubes = o.cubes
if o.solo:
jeu.lancer("solo")
elif o.heberger:
jeu.lancer("heberger")
elif o.rejoindre:
jeu.lancer("rejoindre", hote=o.rejoindre)
elif o.entrainement:
jeu.lancer("entrainement", trou=o.entrainement)
elif o.visite:
jeu.lancer("visite", trou=o.visite)
else:
jeu.menu()
app.run()
client/main.py
Cubogolf.cubes
Renvoyez True si le rendu doit être en cubes : self.forcer_cubes (option --cubes) ou le réglage "rendu" vaut "cubes".
Prototype(s)
class Cubogolf(Entity):
...
def cubes(self):
"""Vrai si le rendu doit être en cubes (réglage, ou --cubes en ligne de commande)."""
pass
client/main.py
Cubogolf.montrer
Remplacez l'écran affiché : détruire self.ecran s'il y en a un, puis garder le nouveau (None = plus aucun écran).
Prototype(s)
class Cubogolf(Entity):
...
def montrer(self, ecran):
"""Remplace l'écran de menu affiché par ecran (None = plus aucun écran)."""
pass
client/main.py
Cubogolf.appliquer (provisoire)
Appliquez les réglages qui peuvent l'être tout de suite : window.fullscreen, window.fps_counter.enabled, et la sensibilité de self.jeu.visee s'il y a une partie. (Le changement de rendu viendra au chapitre 12.)
Prototype(s)
class Cubogolf(Entity):
...
def appliquer(self):
"""Applique tout de suite ce qui peut l'être (le reste servira à la prochaine partie)."""
pass
client/main.py
Cubogolf.lancer_local et lancer (provisoire)
lancer_local(mode, trou) crée une partie sans réseau : self.jeu = Jeu(mode, trou, cubes=..., sensibilite=..., balle=...). Le trou : celui demandé, sinon celui des réglages, sans dépasser le nombre de trous.
lancer(mode, hote, trou) provisoire : self.montrer(None) puis self.lancer_local(mode, trou).
Hint(s)
Indice 1 « celui demandé, sinon... »
trou or r["trou"] : si trou vaut None, or donne la deuxième valeur.
Prototype(s)
class Cubogolf(Entity):
...
def lancer_local(self, mode, trou=None):
"""Entraînement ou visite : une partie sans réseau, sur le trou demandé (sinon celui des réglages)."""
pass
class Cubogolf(Entity):
...
def lancer(self, mode, hote=None, trou=None):
"""mode : solo, heberger, rejoindre, entrainement, visite.
hote et trou remplacent les réglages pour cette fois (raccourcis de la ligne de commande).
"""
pass
client/main.py
Cubogolf.menu et input (provisoires)
Pas encore de menu : menu() lance directement la visite (self.lancer("visite")), et input(touche) ne fait rien (return).
Prototype(s)
class Cubogolf(Entity):
...
def menu(self):
"""Affiche l'écran d'accueil."""
pass
class Cubogolf(Entity):
...
def input(self, touche):
"""Échap : ouvre la pause, ferme les Réglages, ou reprend la partie."""
pass
Vérification
python jouer.py --visite 1 --cubes : les trous s'affichent (N / P), les flèches font tourner la caméra, la molette zoome. Avec --visite 3 (sans --cubes) : le rendu texturé fourni. La barre de jauge vide en bas est normale (chapitre 05).
Valider le chapitre
Jalon V0 : un trou s'affiche en 3D sur les 4 PC. python tools/progression.py 3.
Pour aller plus loin (facultatif)
Un damier (une case sur deux un peu plus foncée) en version cubes. Afficher les points de passage (cases roses).
Questions de soutenance
Pourquoi un seul parent monde pour tous les cubes ?
Pourquoi multiplier les vitesses par dt ?
Pourquoi cube_case est-elle séparée de creer_monde_cubes ?
Chapitre 04
Physique sur terrain plat
QuiPersonne 1Semaines3-4Difficulté★★☆VersionV1
shared/physics.py
Lore
Une balle, c'est une position, une vitesse, et une suite de petites décisions, soixante fois par seconde : ralentir, avancer, cogner, s'arrêter. Le joueur ne verra jamais ces décisions. Il verra seulement si elles sont justes.
Objectif
Une balle tirée roule, ralentit, rebondit sur les murs sans jamais les traverser, tombe dans le trou si elle est assez lente, et s'arrête. Tout en Python pur : testé sans fenêtre, réutilisé par le serveur et le bot. Chaque étape est une petite fonction ; sous_pas les appelle dans l'ordre.
Cours · Pas de temps fixe
On ne calcule pas le mouvement « en continu » : on avance le temps par petits pas de dt = 1/60 s. À chaque pas :
vitesse = vitesse + accélération × dt
position = position + vitesse × dt
Un pas fixe rend la simulation déterministe : le même tir donne toujours exactement le même trajet, quel que soit l'ordinateur.
Indispensable pour le réseau et pour le bot.
Cours · La machine à états
stateDiagram-v2
[*] --> arret
arret --> sol: tirer()
sol --> arret: vitesse < VITESSE_ARRET
sol --> trou: lente au-dessus du trou
sol --> air: plus de sol (chapitres 06-07)
air --> sol: atterrissage
Chaque état a ses règles : au sol, le frottement agit ; en l'air, seulement la gravité ; à l'arrêt, rien.
Cours · Un sous-pas, dans l'ordre
sous_pas(balle, parcours, dt, evenements)
1. appliquer_forces au sol : frottement (+ pente au ch. 07) ; en l'air : gravité (ch. 06)
2. heurter_murs avancer en x et z, rebondir sur les murs -> "mur"
3. suivre_sol la hauteur du terrain (décoller : ch. 06)
4. tombe_dans_le_trou -> état "trou" -> "trou"
5. s_arrete -> état "arret" -> "arret"
Cours · Collision axe par axe
Pour un mur, on teste séparément le déplacement en x et le déplacement en z. Si le pas en x entre dans un mur, on inverse vx
(et on garde z) : la balle rebondit comme une bille de billard contre une bande. On teste le bord de la balle qui avance
(x ± rayon), pas son centre.
Pourquoi ?
Pourquoi écrire sa propre physique plutôt qu'utiliser un moteur ? On veut un minigolf prévisible sur une grille, une physique que le bot peut rejouer en secret 40 fois par coup, et un code qu'on comprend entièrement. Voir docs/03_physique.md.
shared/physics.py — la balle
shared/physics.py
La dataclass Balle
Écrivez la dataclass Balle avec exactement les champs du prototype. y est la hauteur du bas de la balle (le point qui touche le sol).
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de shared/physics.py
"""Physique maison d'une balle de golf sur la grille. [difficulté : ★★☆, rampes ★★★]
Python pur, sans Ursina. Une balle a une position (x, y, z), une vitesse
(vx, vy, vz) et un état :
'arret' : immobile, le joueur peut tirer
'sol' : roule en suivant le terrain
'air' : en vol, seule la gravité agit
'trou' : dans le trou
y est la hauteur du BAS de la balle (le point qui touche le sol).
Un sous-pas, dans l'ordre (chaque étape est une petite fonction) :
appliquer_forces pente + frottement au sol, gravité en l'air
heurter_murs déplacement horizontal, rebond sur les murs
suivre_sol / voler suivre le terrain, décoller, retomber, rebondir
perdue eau ou vide -> replacer
tombe_dans_le_trou, s_arrete
"""
import math
from dataclasses import dataclass
from shared import constants as C
Prototype(s)
@dataclass
class Balle:
x: float
y: float
z: float
vx: float = 0.0
vy: float = 0.0
vz: float = 0.0
etat: str = "arret"
coups: int = 0
dernier_tir: tuple = (0.0, 0.0, 0.0) # où revenir après l'eau ou le vide
Code example(s)
from shared.physics import Balle
b = Balle(1.5, 0.0, 2.5)
print(b)
print(Balle(0, 0, 0, vx=2.0, etat="sol").vx)
int(-0.5) vaut 0, mais math.floor(-0.5) vaut -1. Juste à gauche de la grille, la case est -1 (du vide), pas 0 : utilisez math.floor.
Prototype(s)
def hauteur_sol(parcours, x, z):
"""Hauteur du terrain au point (x, z), ou None au-dessus du vide.
Une rampe monte de 0 à HAUTEUR_HAUT dans sa direction : on regarde où on
est DANS la case (fx, fz entre 0 et 1) pour savoir à quelle hauteur.
"""
pass
Code example(s)
from shared.physics import hauteur_sol
p = parcours_ascii([".h#~s_"])
print([hauteur_sol(p, x + 0.5, 0.5) for x in range(6)])
print(hauteur_sol(p, -0.5, 0.5), hauteur_sol(p, 0.99, 0.01))
Output
[0.0, 0.5, 1.0, -0.15, 0.0, None]
None 0.0
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
pente (provisoire)
Pour l'instant, le terrain est plat partout : renvoyez (0.0, 0.0). Les rampes au chapitre 07.
Prototype(s)
def pente(parcours, x, z):
"""Pente (dh/dx, dh/dz) du terrain sous (x, z). (0, 0) hors des rampes."""
pass
shared/physics.py
frottement
Renvoyez la décélération du sol sous (x, z) : C.FROTTEMENT du type de la case s'il y est (sable, glace...), sinon celui de l'herbe.
Hint(s)
Indice 1 Une valeur par défaut
dico.get(cle, defaut) renvoie defaut si la clé n'existe pas.
Prototype(s)
def frottement(parcours, x, z):
"""Décélération (unités/s²) du sol sous (x, z) : sable, glace, sinon herbe."""
pass
Code example(s)
from shared.physics import frottement
p = parcours_ascii([".si#S"])
print([frottement(p, x + 0.5, 0.5) for x in range(5)])
Output
[1.4, 5.0, 0.35, 1.4, 1.4]
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
bloque
Lore
Un mur n'est qu'une marche trop haute. Pas besoin d'un cas spécial : la géométrie suffit.
Renvoyez True si le sol en (x, z) existe et est plus haut que y + C.MARCHE (une balle à la hauteur y ne peut pas y monter).
Prototype(s)
def bloque(parcours, x, z, y):
"""Vrai si le terrain en (x, z) est une marche plus haute que la balle (mur, flanc de rampe)."""
pass
Code example(s)
from shared.physics import bloque
p = parcours_ascii([".h#_"])
print([bloque(p, x + 0.5, 0.5, 0.0) for x in range(4)])
print(bloque(p, 1.5, 0.5, 0.5), bloque(p, 2.5, 0.5, 0.95))
Output
[False, True, True, False]
False False
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py — tirer
shared/physics.py
nouvelle_balle
Renvoyez une balle immobile au centre de la case de départ, posée sur le sol, avec dernier_tir = sa position.
Prototype(s)
def nouvelle_balle(parcours):
"""Balle immobile au centre de la case de départ."""
pass
Code example(s)
from shared.physics import nouvelle_balle
p = parcours_ascii(["#####", "#S.O#", "#####"])
b = nouvelle_balle(p)
print(b.position(), b.etat, b.coups, b.dernier_tir)
Output
(1.5, 0.0, 1.5) arret 0 (1.5, 0.0, 1.5)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
tirer
Lancez la balle dans la direction angle_deg (0 = Est, 90 = Nord) avec la puissance puissance (de 0 à 1).
Étapes
Borner la puissance entre 0 et 1 ; vitesse v = puissance × C.VITESSE_TIR_MAX.
vx = v × cos(a), vz = v × sin(a) (a en radians), vy = 0.
Noter dernier_tir = la position avant le tir (pour l'eau, chapitre 06).
+1 coup ; état "sol".
Prototype(s)
def tirer(balle, angle_deg, puissance):
"""Lance la balle. angle_deg : 0 = Est (+x), 90 = Nord (+z). puissance : 0 à 1."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
appliquer_forces (au sol)
Pour l'instant : si la balle est au sol, la ralentir du frottement de la case où elle se trouve. (La gravité en l'air au chapitre 06, la pente au chapitre 07.)
Prototype(s)
def appliquer_forces(balle, parcours, dt):
"""Au sol : la pente puis le frottement. En l'air : la gravité."""
pass
Code example(s)
from shared.physics import Balle, appliquer_forces
p = parcours_ascii(["..ss"])
for x in (0.5, 2.5):
b = Balle(x, 0.0, 0.5, vx=3.0, etat="sol")
appliquer_forces(b, p, 0.5)
print(x, round(b.vx, 3))
Output
0.5 2.3
2.5 0.5
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
bord
Renvoyez la coordonnée du bord de la balle qui avance : position plus un rayon (C.BALLE_RAYON) dans le sens de vitesse.
Hint(s)
Indice 1 Le signe
math.copysign(a, b) renvoie a avec le signe de b : copysign(0.12, -3) vaut -0.12.
Prototype(s)
def bord(position, vitesse):
"""Le bord de la balle qui avance : un rayon plus loin, dans le sens de la vitesse."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
suivre_sol (terrain plat)
La balle est au sol et le terrain sous elle est à la hauteur h : pour l'instant, posez-la dessus (balle.y = h). Décoller, c'est au chapitre 06.
Prototype(s)
def suivre_sol(balle, h, dt, evenements):
"""Balle au sol : elle suit le terrain (hauteur h), ou décolle s'il n'y a plus de sol sous elle."""
pass
Code example(s)
from shared.physics import Balle, suivre_sol
b = Balle(0.5, 0.0, 0.5, etat="sol")
ev = []
suivre_sol(b, 0.0, 1 / 60, ev)
print(b.y, b.etat, ev)
Output
0.0 sol []
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
tombe_dans_le_trou
Renvoyez True si la balle est à moins de C.TROU_RAYON du centre du trou et plus lente que C.TROU_VITESSE_MAX. (Trop vite, elle passe par-dessus, comme au vrai golf.)
Prototype(s)
def tombe_dans_le_trou(balle, parcours):
"""Vrai si la balle est au-dessus du trou ET assez lente pour y tomber."""
pass
Code example(s)
from shared.physics import Balle, tombe_dans_le_trou
p = parcours_ascii(["S..O."])
for x, vx in [(3.5, 1.0), (3.7, 1.0), (3.5, 3.0), (2.5, 0.0)]:
print(x, vx, tombe_dans_le_trou(Balle(x, 0.0, 0.5, vx=vx, etat="sol"), p))
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
pas : découper en sous-pas
Avancez la balle de dt secondes en plusieurs sous-pas, pour qu'elle n'avance jamais de plus de C.DEPLACEMENT_MAX à la fois. Renvoyez la liste des événements.
Étapes
À l'arrêt ou dans le trou : renvoyer [].
La vitesse totale : √(vx² + vy² + vz²).
n = max(1, ceil(vitesse × dt / C.DEPLACEMENT_MAX)).
n fois : sous_pas(balle, parcours, dt / n, evenements) ; s'arrêter dès que la balle est à l'arrêt ou dans le trou.
Pourquoi ?
Une balle à 9 unités/s avance de 0,15 case par pas : elle pourrait sauter par-dessus le coin d'un mur. Avec des sous-pas de 0,08 case au plus, impossible.
Prototype(s)
def pas(balle, parcours, dt=C.PAS_PHYSIQUE):
"""Avance la balle de dt secondes. Renvoie la liste des événements.
Événements : 'mur', 'decollage', 'rebond', 'atterrissage', 'eau', 'vide',
'trou', 'arret'. Si la balle va vite, on découpe dt en petits sous-pas
pour qu'elle ne traverse jamais un mur.
"""
pass
Code example(s)
from shared.physics import Balle, pas
p = parcours_ascii(["S" + "." * 20])
b = Balle(0.5, 0.0, 0.5, vx=9.0, etat="sol")
print(pas(b, p), round(b.x, 3))
print(pas(Balle(0.5, 0.0, 0.5), p))
Output
[] 0.65
[]
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
simuler_tir
Jouez un tir en entier sur une copie de la balle, et renvoyez (copie, evenements, trajectoire). Le bot s'en servira pour essayer des tirs en secret.
Étapes
La copie : Balle(balle.x, balle.y, balle.z, coups=balle.coups) ; tirer la copie.
trajectoire = [position de départ] si trace, sinon [].
Tant que la copie n'est ni à l'arrêt ni dans le trou, et que moins de duree_max secondes sont passées : un pas de C.PAS_PHYSIQUE (et la position dans la trajectoire si trace).
Prototype(s)
def simuler_tir(parcours, balle, angle_deg, puissance, duree_max=30.0, trace=False):
"""Simule un tir jusqu'à l'arrêt, SANS toucher à la balle donnée (on joue une copie).
Renvoie (balle_finale, evenements, trajectoire). trajectoire = [(x, y, z), ...]
si trace=True, sinon [].
"""
pass
Code example(s)
from shared.physics import nouvelle_balle, simuler_tir
p = parcours_ascii(["#S..O" + "." * 30 + "#"])
b = nouvelle_balle(p)
for puissance in (0.2, 0.4, 1.0):
fin, ev, _ = simuler_tir(p, b, 0, puissance)
print(puissance, round(fin.x, 2), fin.etat, fin.coups, sorted(set(ev)))
print(b.coups, b.etat)
print(len(simuler_tir(p, b, 0, 0.2, trace=True)[2]) > 10)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
Vérification
python tools/trajectory_plot.py 1 60 1.0 : la trajectoire rebondit sur les murs sans jamais sortir.
Valider le chapitre
python tools/progression.py 4 : tout validé. Avant de pousser : chaque fonction a-t-elle une seule responsabilité ? sous_pas fait-il moins de 20 lignes ?
Pour aller plus loin (facultatif)
Mesurez le temps d'un simuler_tir (module time) : le bot en fera 40 par coup.
Questions de soutenance
Pourquoi un pas de temps fixe, et pas le temps réel entre deux images ?
Montrez sur un schéma pourquoi une balle rapide traverserait un mur sans les sous-pas.
Pourquoi simuler_tir travaille-t-il sur une copie ?
Chapitre 05
Viser, tirer, jouer seul
QuiPersonne 2Semaines4-5Difficulté★★☆VersionV1
client/camera_aim.pyclient/hud.pyclient/main.py
Lore
Le parcours est là, la balle attend sur le départ. Il manque un geste : viser, sentir la jauge monter, relâcher. Et une caméra qui suit la balle comme un spectateur attentif, sans jamais passer derrière un mur.
Objectif
python jouer.py --entrainement 1 --cubes : on vise (flèches, souris), on maintient Espace pour charger, on relâche pour tirer, la balle roule sans à-coups, la caméra la suit en douceur. Dans le trou : « Birdie ! », puis le trou suivant. Il vous faut : le chapitre 03 et la physique plate (chapitre 04).
Cours · La boucle de jeu à pas fixe
L'affichage tourne à la vitesse de l'écran (60, 144 images par seconde...), la physique à pas fixes de 1/60 s. On accumule le
temps écoulé et on fait autant de pas qu'il faut :
accu += time.dt (sans dépasser 0.25 s)
tant que accu >= PAS_PHYSIQUE :
avant = position de la balle
pas(balle, parcours)
accu -= PAS_PHYSIQUE
Cours · L'interpolation
Entre deux pas de physique, l'écran peut afficher plusieurs images. Si on dessine la balle à sa position physique, elle avance par
saccades. On la dessine donc entre la position du pas précédent et celle du pas actuel, à la fraction a = accu / PAS_PHYSIQUE :
dessin = avant + (actuelle - avant) × a (a = 0 : avant ; a = 1 : actuelle)
La physique reste exacte, l'image devient fluide.
Cours · Le lissage exponentiel
Pour qu'une caméra suive sans à-coups, à chaque image elle fait une fraction du chemin vers sa cible :
f = 1 - e^(-vitesse × dt). Avec un f fixe, la caméra irait deux fois plus vite à 120 images par seconde qu'à 60 ;
avec cette formule, deux petits pas valent exactement un grand.
Pourquoi ?
Pourquoi la physique ne suit-elle pas le rythme de l'écran ? Un écran à 144 Hz et un écran à 60 Hz doivent donner exactement la même trajectoire, sinon deux joueurs ne verraient pas la même partie et le bot ne prévoirait plus juste. Voir docs/03_physique.md.
client/camera_aim.py — fonctions pures
client/camera_aim.py
jauge
Lore
Maintenir Espace : la jauge monte, redescend, remonte... Le bon moment pour relâcher, c'est le talent du joueur.
Renvoyez la puissance (de 0 à 1) après temps_appuye secondes d'appui : elle monte de 0 à 1 pendant la première moitié de PERIODE_JAUGE, redescend à 0 pendant la seconde, et recommence.
Étapes
La phase, entre 0 et 1 : (temps_appuye / PERIODE_JAUGE) % 1.0.
Phase < 0,5 : on monte, 2 × phase.
Sinon : on descend, 2 × (1 - phase).
Prototype(s)
def jauge(temps_appuye):
"""Puissance 0 -> 1 -> 0 -> ... selon le temps passé à appuyer (en dents de scie aller-retour)."""
pass
Code example(s)
from client.camera_aim import jauge
print([round(jauge(t), 2) for t in (0, 0.4, 0.8, 1.2, 1.6, 2.0, 3.6)])
Output
[0.0, 0.5, 1.0, 0.5, 0.0, 0.5, 0.5]
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py
lisser
Rapprochez actuel de cible d'une fraction f = 1 - e^(-vitesse × dt) du chemin. Doit marcher pour un nombre et pour un tuple (position).
Étapes
f = 1 - math.exp(-vitesse * dt).
Un nombre (isinstance(actuel, (int, float))) : actuel + (cible - actuel) × f.
Un tuple : la même chose pour chaque composante, en tuple.
Prototype(s)
def lisser(actuel, cible, vitesse, dt):
"""Rapproche actuel de cible en douceur (marche pour un nombre ou un tuple).
À chaque image on fait une partie du chemin : 1 - e^(-vitesse*dt).
Ça donne le même mouvement quel que soit le nombre d'images par seconde.
"""
pass
Code example(s)
from client.camera_aim import lisser
print(round(lisser(0.0, 10.0, 10, 1 / 60), 4))
print([round(v, 4) for v in lisser((0, 0), (2, 4), 10, 1 / 60)])
un = lisser(0.0, 1.0, 5, 1 / 30)
deux = lisser(lisser(0.0, 1.0, 5, 1 / 60), 1.0, 5, 1 / 60)
print(round(un, 6) == round(deux, 6))
Output
1.5352
[0.307, 0.6141]
True
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py
interpoler
Renvoyez le point à la fraction a du chemin entre avant et apres (deux positions (x, y, z)), en tuple. a = 0 donne avant, a = 1 donne apres.
Prototype(s)
def interpoler(avant, apres, a):
"""Le point à la fraction a (0 = avant, 1 = apres) du chemin entre deux positions (x, y, z)."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py
distance_libre
Lore
Une caméra qui passe derrière un mur ne montre plus rien. Elle doit savoir jusqu'où elle peut reculer.
Renvoyez la distance maximale à laquelle la caméra peut se placer, de la cible vers camera_pos, avant que le terrain ne cache la cible.
Étapes
total = math.dist(cible, camera_pos).
Pour i de 1 à echantillons : le point à la fraction i / echantillons du segment cible → caméra.
Si le terrain sous ce point existe (hauteur(x, z) n'est pas None) et que le point est sous h + marge : renvoyer la distance du point précédent, total × (i - 1) / echantillons, mais au moins DISTANCE_MIN × 0.6.
Rien ne gêne : renvoyer total.
Prototype(s)
def distance_libre(cible, camera_pos, hauteur, marge=0.35, echantillons=24):
"""Distance max avant que le terrain ne cache la cible.
hauteur : fonction (x, z) -> hauteur du terrain, ou None au-dessus du vide.
"""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/camera_aim.py — la visée complète
client/camera_aim.py
Visee.placer_camera (complète)
La caméra suit la cible en douceur et monte si un mur cache la balle.
Étapes
cible = self.cible() ; self.vue = la cible la première fois, sinon lisser(self.vue, cible, SUIVI, dt).
Partir du tangage et de la distance voulus (self.tangage, self.distance).
Si self.hauteur existe : tant que le tangage est sous TANGAGE_MAX et que distance_libre est plus petite que la distance, monter le tangage de 4°. Puis, en dernier recours, raccourcir : distance = distance_libre.
Lisser self.tangage_vu et self.distance_vue vers ces valeurs (vitesse 6).
camera.position = position_camera(self.vue, self.angle, self.tangage_vu, self.distance_vue) ; regarder un peu au-dessus de la balle : camera.look_at((x, y + 0.3, z)).
Pourquoi ?
Pourquoi monter plutôt qu'avancer ? Une caméra qui s'approche colle la balle et on ne voit plus où viser ; une caméra qui monte garde la vue d'ensemble.
Prototype(s)
class Visee(Entity):
...
def placer_camera(self, dt):
"""Place la caméra derrière la cible (lissée), en montant si un mur la cache,
et la fait regarder la cible."""
pass
client/camera_aim.py
Visee.placer_fleche
Montrez la flèche de visée devant la balle quand on peut tirer.
Couleur : blanche au repos ; en charge, du blanc au rouge avec la puissance (color.rgba32(255, int(255 × (1 - p)), 60, 200)).
Information
Ursina compte les angles à partir du Nord et dans l'autre sens que les maths : d'où 90 - angle.
Prototype(s)
class Visee(Entity):
...
def placer_fleche(self):
"""Montre la flèche de visée devant la balle quand on peut tirer (plus longue quand on charge)."""
pass
client/camera_aim.py
Visee.update (complète)
À chaque image : tourner, placer la caméra, placer la flèche ; et si on charge (self.charge n'est pas None), charge += dt et puissance = jauge(charge).
Prototype(s)
class Visee(Entity):
...
def update(self):
"""À chaque image : tourner, placer la caméra et la flèche, faire avancer la jauge."""
pass
client/camera_aim.py
Visee.input (complète)
Ajoutez le tir au zoom du chapitre 03.
Étapes
"space" ou "left mouse down", si on peut tirer : commencer à charger (charge = 0, puissance = 0).
"space up" ou "left mouse up", si on chargeait : arrêter de charger (charge = None), et si on peut toujours tirer et que la puissance dépasse 0,02, appeler self.sur_tir(self.angle, puissance).
Renvoyez le texte du haut de l'écran, exactement au format de l'exemple (deux espaces avant la parenthèse, quatre avant « Coups »).
Prototype(s)
def ligne_trou(numero, total, nom, par, coups):
"""Le texte du haut de l'écran : « Trou 2/9 - Le virage (par 3) Coups : 1 »."""
pass
Code example(s)
from client.hud import ligne_trou
print(ligne_trou(2, 9, "Le virage", 3, 1))
print(ligne_trou(9, 9, "Fin", 4, 0))
Output
Trou 2/9 - Le virage (par 3) Coups : 1
Trou 9/9 - Fin (par 4) Coups : 0
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/hud.py
Hud.trou
Mettez ligne_trou(...) dans self.haut.text (le nom et le par viennent du parcours).
Prototype(s)
class Hud(Entity):
...
def trou(self, numero, total, parcours, coups):
"""Met à jour la ligne du haut (voir ligne_trou)."""
pass
client/hud.py
Hud.puissance
Affichez la jauge : p entre 0 et 1, ou None pour la cacher.
Étapes
Jauge et fond visibles seulement si p n'est pas None.
None : return.
Largeur self.jauge.scale_x = 0.5 × p ; couleur de l'orange au rouge : color.rgb32(255, int(200 × (1 - p)), 40).
Prototype(s)
class Hud(Entity):
...
def puissance(self, p):
"""p entre 0 et 1, ou None pour cacher la jauge."""
pass
client/hud.py
Hud.annonce et Hud._effacer
Un gros texte au centre, effacé après duree secondes — sauf si une autre annonce l'a remplacé entre-temps.
Étapes
annonce : écrire le texte, prendre un numéro de jeton (le précédent + 1), et programmer invoke(self._effacer, jeton, delay=duree).
_effacer(jeton) : effacer seulement si jeton est toujours le dernier.
Hint(s)
Indice 1 À quoi sert le jeton ?
« Plouf ! » à 0 s (effacé à 1,8 s), puis « Dans le trou ! » à 1 s : sans jeton, l'effacement prévu à 1,8 s efface le deuxième message.
Indice 2 Le premier jeton
getattr(self, "_jeton", 0) vaut 0 si l'attribut n'existe pas encore.
Prototype(s)
class Hud(Entity):
...
def annonce(self, texte, duree=1.8):
"""Gros texte au centre, effacé après duree secondes (sauf si remplacé avant)."""
pass
class Hud(Entity):
...
def _effacer(self, jeton):
"""Efface le texte du centre, sauf si une annonce plus récente l'a remplacé."""
pass
client/hud.py
Hud.montrer_panneau et Hud.cacher_panneau
Un texte au centre sur un fond sombre (scores, salon...). montrer_panneau(texte) : le texte, panneau et fond visibles si le texte n'est pas vide, fond un peu plus grand que le texte (panneau.width + 0.08, pareil en hauteur). cacher_panneau() : les deux cachés.
Prototype(s)
class Hud(Entity):
...
def montrer_panneau(self, texte):
"""Affiche un texte au centre de l'écran sur un fond sombre (scores, salon...)."""
pass
class Hud(Entity):
...
def cacher_panneau(self):
"""Cache le panneau et son fond."""
pass
client/main.py — jouer
client/main.py
Les nouveaux imports
Ajoutez ces imports au début de client/main.py.
Code fourni
Code fourni : à recopier tel quel
# Dans le début de client/main.py, remplacez l'import de Visee et ajoutez deux lignes :
from client.camera_aim import Visee, interpoler, lisser # noqa: E402
from shared.physics import hauteur_sol, nouvelle_balle, pas, tirer # noqa: E402
from shared.scores import nom_score # noqa: E402
client/main.py
Jeu.maj_hud
Réécrivez la ligne du haut : self.hud.trou(numero, total, parcours, coups de la balle).
Prototype(s)
class Jeu(Entity):
...
def maj_hud(self):
"""Réécrit la ligne du haut : trou, nom, par, coups."""
pass
client/main.py
Jeu.charger (complète)
Hors visite, après la mise à jour du décor : la balle.
class Jeu(Entity):
...
def charger(self, numero):
"""Charge le trou numero : construit le décor (cubes ou texturé), remet la balle au départ
(sauf en visite), met à jour le HUD et annonce le nom du trou."""
pass
client/main.py
Jeu.peut_tirer et hauteur_terrain (complètes)
peut_tirer() : une balle, à l'arrêt, et le trou pas fini. hauteur_terrain(x, z) : hors visite, hauteur_sol(self.parcours, x, z) (la caméra en a besoin pour monter au-dessus des murs).
Prototype(s)
class Jeu(Entity):
...
def peut_tirer(self):
"""Vrai si on a une balle, à l'arrêt, et que le trou n'est pas fini."""
pass
class Jeu(Entity):
...
def hauteur_terrain(self, x, z):
"""La hauteur du terrain en (x, z) pour la caméra (None en visite : pas de collision)."""
pass
client/main.py
Jeu.tirer
La visée appelle cette méthode au relâchement : tirer(self.balle, angle, puissance) (la physique), puis self.maj_hud() (un coup de plus). L'envoi réseau viendra au chapitre 11.
Prototype(s)
class Jeu(Entity):
...
def tirer(self, angle, puissance):
"""Tire la balle, met à jour le HUD, prévient le serveur (shot) en ligne."""
pass
client/main.py
Jeu.avancer : les pas fixes
Faites autant de pas de physique qu'il faut pour rattraper dt (voir le cours). Renvoyez tous les événements.
Étapes
self.accu = min(self.accu + dt, 0.25) (au plus 0,25 s : après un gel de la fenêtre, on ne rattrape pas 10 s d'un coup).
Tant que accu >= C.PAS_PHYSIQUE : self.avant = position, un pas, accu -= PAS_PHYSIQUE.
Si le pas contient "eau" ou "vide" : self.avant = position à nouveau (voir le danger).
Danger(s)
Une balle replacée (eau, vide) se téléporte : si on interpole entre l'ancienne et la nouvelle position, on la voit glisser à travers le décor. Après un replacement, avant = la nouvelle position.
Prototype(s)
class Jeu(Entity):
...
def avancer(self, dt):
"""Pas fixes de 1/60 s pour rattraper dt (au plus 0,25 s d'un coup). Renvoie les événements."""
pass
client/main.py
Jeu.dessiner_balle : l'interpolation
Placez la sphère entre le pas précédent et le pas actuel : interpoler(self.avant, position, accu / PAS_PHYSIQUE). La physique suit le bas de la balle, Ursina place le centre de la sphère : ajoutez C.BALLE_RAYON en y.
Information
Le roulement de la sphère (rouler) viendra au chapitre 13 : ne l'écrivez pas encore.
Prototype(s)
class Jeu(Entity):
...
def dessiner_balle(self):
"""Place la sphère entre le pas précédent et le pas actuel (interpolation), et la fait rouler au sol."""
pass
client/main.py
Jeu.annoncer
Annoncez « Plouf ! +1 coup » sur l'événement "eau", « Dans le vide ! +1 coup » sur "vide", et dans ces deux cas mettez le HUD à jour (un coup a été ajouté).
Prototype(s)
class Jeu(Entity):
...
def annoncer(self, evenements):
"""Les annonces des événements de ce tour : eau, vide (et le HUD, car un coup a été ajouté)."""
pass
client/main.py
Jeu.physique : assembler
À chaque image : avancer(time.dt), dessiner_balle(), annoncer(...). Puis, si le trou n'est pas déjà fini et que la balle est dans le trou (ou à l'arrêt avec C.COUPS_MAX coups) : trou_fini(), une seule fois.
Prototype(s)
class Jeu(Entity):
...
def physique(self):
"""À chaque image : les pas de physique, le dessin de la balle, les annonces, la fin du trou,
l'envoi de sa position en ligne."""
pass
client/main.py
Jeu.trou_fini
Le trou est fini.
Étapes
self.fini = True.
Dans le trou : annoncer le score (nom_score) pendant 3 s : « Birdie ! », ou « Dans le trou (+4) » quand le nom commence par + ; cacher la sphère.
Sinon : « 10 coups, trou fini ».
En entraînement : charger le trou suivant 2,5 s plus tard : invoke(self.charger, numero_voisin(...), delay=2.5).
Prototype(s)
class Jeu(Entity):
...
def trou_fini(self):
"""Le trou est fini (balle dans le trou ou COUPS_MAX) : annonce le score spécial ;
en ligne envoie holed, en entraînement charge le trou suivant après 2,5 s."""
pass
client/main.py
Jeu.update et la touche R
update() : physique() s'il y a une balle, puis la jauge : hud.puissance(visee.puissance) si on charge, sinon hud.puissance(None). Dans input : R recommence le trou en entraînement.
Prototype(s)
class Jeu(Entity):
...
def update(self):
"""À chaque image : lire les messages du réseau, faire glisser et rouler les balles des autres,
faire avancer la physique (sauf en pause hors ligne), afficher la jauge."""
pass
Vérification
python jouer.py --entrainement 1 --cubes puis sans --cubes : la jauge monte et descend, le tir part dans la direction de la flèche, la balle glisse sans à-coups, contre le mur du départ la caméra monte au lieu de passer derrière, et dans le trou le nom du score s'affiche.
Valider le chapitre
Jalon V1 : on joue seul sur du plat. python tools/progression.py 5.
Pour aller plus loin (facultatif)
Une ombre sous la balle (un disque noir transparent posé au sol). Un son au tir (Audio d'Ursina).
Questions de soutenance
Expliquez l'interpolation avec un schéma : deux pas de physique et trois images.
Pourquoi la caméra monte-t-elle au lieu d'avancer quand un mur cache la balle ?
Chapitre 06
Sable, glace, eau, vide
QuiPersonne 1Semaines5Difficulté★★☆VersionV2
shared/physics.py
Lore
Un parcours tout en herbe, c'est une salle d'attente. Le sable avale les balles trop pressées, la glace emporte les imprudentes, l'eau les rend au point de départ, et le vide... le vide, on n'en revient qu'avec un coup de pénalité.
Objectif
Le sable et la glace marchent déjà (grâce à frottement). Il reste : l'eau, le vide, et un nouvel état, "air", pour une balle qui tombe d'une marche.
Cours · Un nouvel état : en l'air
Au-dessus du vide, hauteur_sol renvoie None : il n'y a plus de sol à suivre. La balle passe à l'état
"air" :
plus de frottement, seule la gravité agit sur sa vitesse verticale : vy -= GRAVITE × dt ;
elle monte ou descend : y += vy × dt ;
si elle retouche le sol, elle se pose (état "sol") ; si elle descend sous LIMITE_CHUTE, elle est perdue.
Cours · sous_pas, version complète
appliquer_forces (au sol : frottement ; en l'air : gravité)
heurter_murs -> "mur"
au sol : suivre_sol (ou décoller -> "decollage")
en l'air : voler (atterrir -> "atterrissage")
perdue ? -> "eau" / "vide", replacer, FIN
pas au sol -> FIN
trou ? arrêt ?
shared/physics.py — le terrain change la balle
shared/physics.py
Vérifier ce qui marche déjà
Lancez l'exemple : le sable freine plus que l'herbe, la glace moins, sans que vous ayez écrit une ligne. Expliquez dans le journal pourquoi, et pourquoi l'herbe haute arrête la balle comme un mur quand on arrive d'en bas.
Code example(s)
from shared.physics import Balle, simuler_tir
for sol in ("." * 30, "s" * 30, "i" * 30):
p = parcours_ascii([sol])
fin, _, _ = simuler_tir(p, Balle(0.5, 0.0, 0.5), 0, 0.5)
print(sol[0], round(fin.x, 2))
fin, ev, _ = simuler_tir(parcours_ascii(["...hhh"]), Balle(0.5, 0.0, 0.5), 0, 0.5)
print("mur" in ev, fin.x < 3)
Output
. 7.69
s 2.49
i 29.5
True True
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
perdue
Renvoyez "eau" si la balle roule (état "sol") sur une case d'eau, "vide" si elle est descendue sous C.LIMITE_CHUTE, sinon None.
Information
Une balle en l'air au-dessus de l'eau n'est pas perdue : elle peut la survoler (un tremplin, chapitre 07).
Prototype(s)
def perdue(balle, parcours):
"""'eau' si la balle roule sur l'eau, 'vide' si elle est tombée trop bas, sinon None."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
sous_pas : l'eau et le vide
Dans sous_pas, juste après avoir suivi le sol : si perdue renvoie une raison, l'ajouter aux événements, replacer(balle), et quitter la fonction (return).
Prototype(s)
def sous_pas(balle, parcours, dt, evenements):
"""Un petit pas de temps. Ajoute ce qui se passe dans la liste evenements."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py — l'état « air »
shared/physics.py
suivre_sol : décoller
Complétez suivre_sol : s'il n'y a plus de sol (h vaut None) ou si le sol est plus bas qu'une marche (h < y - C.MARCHE), la balle décolle au lieu de suivre le sol.
Étapes
État "air" ; événement "decollage".
Elle garde sa vitesse : y += vy × dt (pour l'instant vy vaut 0 ; au chapitre 07 il sera positif au bout d'une rampe).
Sinon (il y a un sol pas trop bas) : y = h comme avant.
Prototype(s)
def suivre_sol(balle, h, dt, evenements):
"""Balle au sol : elle suit le terrain (hauteur h), ou décolle s'il n'y a plus de sol sous elle."""
pass
Code example(s)
from shared.physics import Balle, suivre_sol
for h in (0.0, None, -0.5):
b, ev = Balle(0.5, 0.0, 0.5, etat="sol"), []
suivre_sol(b, h, 1 / 60, ev)
print(h, b.y, b.etat, ev)
Output
0.0 0.0 sol []
None 0.0 air ['decollage']
-0.5 0.0 air ['decollage']
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
appliquer_forces : la gravité
Complétez appliquer_forces : une balle qui n'est pas au sol (en l'air) ne frotte plus ; seule la gravité agit : vy -= C.GRAVITE × dt.
Prototype(s)
def appliquer_forces(balle, parcours, dt):
"""Au sol : la pente puis le frottement. En l'air : la gravité."""
pass
Code example(s)
from shared.physics import Balle, appliquer_forces
from tests.fabrique import parcours_ascii
b = Balle(0.5, 1.0, 0.5, vx=3.0, etat="air")
appliquer_forces(b, parcours_ascii(["..."]), 0.5)
print(b.vx, round(b.vy, 2))
Output
3.0 -4.9
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
voler (sans rebond)
Une balle en l'air : elle monte ou descend (y += vy × dt). Si elle touche le sol (h existe et y <= h) : y = h, vy = 0, état "sol", événement "atterrissage". Le rebond viendra au chapitre 07.
Prototype(s)
def voler(balle, h, dt, evenements):
"""Balle en l'air : elle tombe ; au contact du sol (hauteur h), elle rebondit ou se pose."""
pass
0.2 air []
0.0 0.0 sol ['atterrissage']
-0.2 air []
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
sous_pas : au sol ou en l'air
Dans sous_pas : au sol, suivre_sol ; en l'air, voler. Et après le test de l'eau et du vide : si la balle n'est pas au sol, return (le trou et l'arrêt ne concernent qu'une balle qui roule).
Prototype(s)
def sous_pas(balle, parcours, dt, evenements):
"""Un petit pas de temps. Ajoute ce qui se passe dans la liste evenements."""
pass
Code example(s)
from shared.physics import Balle, simuler_tir
p = parcours_ascii(["hhh......"])
fin, ev, _ = simuler_tir(p, Balle(0.5, 0.5, 0.5), 0, 0.5)
print(fin.y, ev[0], "atterrissage" in ev)
p = parcours_ascii(["...___..."])
fin, ev, _ = simuler_tir(p, Balle(0.5, 0.0, 0.5), 0, 0.6)
print(fin.position(), fin.coups, ev[-1])
Output
0.0 decollage True
(0.5, 0.0, 0.5) 2 vide
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
Vérification
python jouer.py --entrainement 2 --cubes : dans l'eau, « Plouf ! +1 coup » et retour au dernier tir.
Valider le chapitre
Jalon V2. python tools/progression.py 6.
Pour aller plus loin (facultatif)
Un nouveau type de case (de la boue ?) : une couleur dans PALETTE, un frottement, un test à vous.
Questions de soutenance
Pourquoi replacer ajoute-t-il un coup ?
Dessinez la machine à états de la balle avec ses 4 états.
Chapitre 07
Rampes et tremplins
QuiPersonne 1Semaines6-7Difficulté★★★VersionV3
shared/physics.py
Lore
Au fond du parc, une rampe orange s'arrête net au-dessus du vide. Les anciens joueurs disaient qu'avec assez d'élan, la balle s'envole et retombe de l'autre côté. Ceux qui manquaient d'élan, eux, ne disaient rien.
Objectif
Une balle qui monte trop lentement redescend ; qui arrive en haut d'une rampe suivie d'une case plus basse décolle et vole d'autant plus loin qu'elle allait vite ; qui arrive par le côté rebondit ; qui retombe fort rebondit un peu.
Dans la case, fz = z - cz va de 0 (bord Sud) à 1 (bord Nord) ; fx pareil d'Ouest en Est.
Rampe
Monte vers
Hauteur dans la case
Pente (dh/dx, dh/dz)
rampe_N
Nord (+z)
fz × H
(0, H)
rampe_S
Sud (-z)
(1 - fz) × H
(0, -H)
rampe_E
Est (+x)
fx × H
(H, 0)
rampe_O
Ouest (-x)
(1 - fx) × H
(-H, 0)
Cours · La pente fait accélérer ou freiner
Sur une pente, la gravité tire la balle vers le bas de la pente : vx -= GRAVITE × pente_x × dt (et pareil en z).
Une balle qui monte ralentit, une balle qui descend accélère.
Cours · Pourquoi la balle décolle vers le haut
Au sol, à chaque sous-pas, la balle suit le terrain : elle monte de h - y. Sa vitesse verticale est donc
vy = (h - y) / dt. Au bout d'une rampe, le sol disparaît : la balle garde ce vy positif et part en cloche.
Warning(s)
Le plan du projet classe cette partie « besoin d'aide moyen » : faites vérifier vos raisonnements et vos schémas par un coéquipier ou un assistant, jamais écrire la fonction.
shared/physics.py — la forme des rampes
shared/physics.py
hauteur_rampe
Renvoyez la hauteur d'une rampe au point (fx, fz) de sa case, avec le tableau du cours. type_case est l'un des quatre types de rampe.
Prototype(s)
def hauteur_rampe(type_case, fx, fz):
"""Hauteur d'une rampe au point (fx, fz) de sa case (fx, fz entre 0 et 1) : de 0 à HAUTEUR_HAUT."""
pass
Code example(s)
from shared.physics import hauteur_rampe
for t in ("rampe_N", "rampe_S", "rampe_E", "rampe_O"):
print(t, [round(hauteur_rampe(t, f, 0.25), 3) for f in (0.0, 0.5, 1.0)], round(hauteur_rampe(t, 0.5, 0.75), 3))
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
hauteur_sol : les rampes
Complétez hauteur_sol : si le type commence par "rampe", renvoyer hauteur_rampe(t, x - cx, z - cz).
Prototype(s)
def hauteur_sol(parcours, x, z):
"""Hauteur du terrain au point (x, z), ou None au-dessus du vide.
Une rampe monte de 0 à HAUTEUR_HAUT dans sa direction : on regarde où on
est DANS la case (fx, fz entre 0 et 1) pour savoir à quelle hauteur.
"""
pass
Code example(s)
from shared.physics import hauteur_sol
p = parcours_ascii(["^>v<"])
for cx in range(4):
print(p.type_case(cx, 0), [round(hauteur_sol(p, cx + f, 0.25), 3) for f in (0.0, 0.5, 0.999)])
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py — monter, descendre, s'envoler
shared/physics.py
appliquer_forces : la pente
Au sol, avant le frottement : vx -= C.GRAVITE × pente_x × dt et vz -= C.GRAVITE × pente_z × dt.
Prototype(s)
def appliquer_forces(balle, parcours, dt):
"""Au sol : la pente puis le frottement. En l'air : la gravité."""
pass
Code example(s)
from shared.physics import Balle, appliquer_forces
p = parcours_ascii([">.<"])
for cx, vx in [(0, 2.0), (2, 2.0), (1, 2.0)]:
b = Balle(cx + 0.5, 0.25, 0.5, vx=vx, etat="sol")
appliquer_forces(b, p, 0.1)
print(p.type_case(cx, 0), round(b.vx, 3))
Output
rampe_E 1.37
rampe_O 2.35
herbe 1.86
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
s_arrete : seulement sur du plat
Complétez s_arrete : la balle ne s'arrête que si elle est lente et que la pente est nulle. Sinon une balle presque arrêtée en haut d'une rampe y resterait collée au lieu de redescendre.
Prototype(s)
def s_arrete(balle, parcours):
"""Vrai si la balle est assez lente pour s'arrêter, et pas sur une rampe (elle redescendrait)."""
pass
Code example(s)
from shared.physics import Balle, s_arrete
p = parcours_ascii([".^"])
print(s_arrete(Balle(0.5, 0.0, 0.5, vx=0.01, etat="sol"), p), s_arrete(Balle(1.5, 0.25, 0.5, vx=0.01, etat="sol"), p))
Output
True False
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
voler : rebondir
Complétez voler : au contact du sol, si la balle tombe vite (-vy > C.REBOND_SOL_MIN), elle rebondit : vy = -vy × C.REBOND_SOL, elle reste en l'air, événement "rebond". Sinon elle se pose comme avant.
Prototype(s)
def voler(balle, h, dt, evenements):
"""Balle en l'air : elle tombe ; au contact du sol (hauteur h), elle rebondit ou se pose."""
pass
Code example(s)
from shared.physics import Balle, voler
for vy in (-4.0, -1.0):
b, ev = Balle(0.5, 0.05, 0.5, vy=vy, etat="air"), []
voler(b, 0.0, 0.05, ev)
print(vy, round(b.vy, 3) + 0.0, b.y, b.etat, ev)
Output
-4.0 1.2 0.0 air ['rebond']
-1.0 0.0 0.0 sol ['atterrissage']
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
suivre_sol : la vitesse verticale
Complétez suivre_sol : quand la balle suit le sol, notez d'abord sa vitesse verticale vy = (h - y) / dt, puisy = h. C'est ce vy qu'elle gardera en décollant au bout d'une rampe.
Hint(s)
Indice 1 Elle ne va jamais assez loin
Affichez vy au moment du décollage : est-il positif ? Calcul à la main : environ 6 unités/s en haut de la rampe, donc vy ≈ 3, environ 0,7 s de vol, environ 4 cases.
Indice 2 Elle s'arrête net en montant
Le sol monte d'un sous-pas à l'autre ; si un sous-pas avance trop, le sol monte de plus que MARCHE et bloque l'arrête. Vérifiez que pas() découpe selon la vitesse totale, vy compris.
Prototype(s)
def suivre_sol(balle, h, dt, evenements):
"""Balle au sol : elle suit le terrain (hauteur h), ou décolle s'il n'y a plus de sol sous elle."""
pass
Code example(s)
from shared.physics import Balle, simuler_tir, suivre_sol
b, ev = Balle(0.5, 0.1, 0.5, etat='sol'), []
suivre_sol(b, 0.2, 0.05, ev)
print(round(b.vy, 3), b.y)
# TREMPLIN : on part du Sud, une rampe ^, 2 rangées de vide, 14 rangées d'herbe, un mur.
for puissance in (0.3, 0.8, 1.0):
fin, ev, _ = simuler_tir(TREMPLIN, Balle(1.5, 0.0, 0.5), 90, puissance)
print(puissance, round(fin.z, 2), fin.etat, fin.coups, 'decollage' in ev)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/physics.py
Le flanc d'une rampe (aucun code)
Une balle qui arrive sur une rampe par le côté doit rebondir : le côté est une marche. Lancez l'exemple : ça marche sans une ligne de plus. Expliquez pourquoi dans le journal (indice : que vaut hauteur_sol au bord de la rampe ?).
Code example(s)
from shared.physics import Balle, simuler_tir
p = parcours_ascii(["....^...."])
fin, ev, _ = simuler_tir(p, Balle(0.5, 0.0, 0.5), 0, 0.6)
print(round(fin.x, 2), "mur" in ev)
Output
0.93 True
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
Vérification
python tools/trajectory_plot.py 3 90 0.8 : la vue de côté montre la montée, le vol en cloche et le petit rebond.
Valider le chapitre
Jalon V3 : la physique est complète. python tools/progression.py 7.
Pour aller plus loin (facultatif)
Le vent : une petite force constante en l'air, différente à chaque trou.
Questions de soutenance
Pourquoi la balle décolle-t-elle vers le haut au bout d'une rampe ?
Que se passerait-il sans les sous-pas, sur une rampe ?
Chapitre 08
Le protocole réseau
QuiPersonne 3Semaines2-3Difficulté★☆☆VersionV4
shared/protocol.py
Lore
Quatre machines, un câble invisible, et des messages qui arrivent coupés en morceaux ou collés deux par deux. Avant de jouer ensemble, il faut une langue commune, et une façon de savoir où finit chaque phrase.
Objectif
Définir comment le client et le serveur se parlent : un dictionnaire Python, envoyé en JSON, une ligne par message. Ce chapitre ne dépend de personne : à faire dès la semaine 2.
On termine donc chaque message par \n, et on garde les morceaux dans un tampon jusqu'à avoir une ligne complète.
Cours · Les arguments nommés : **champs
def f(type_message, **champs):
print(champs)
f("join", nom="Zoé", balle="rayee") # {'nom': 'Zoé', 'balle': 'rayee'}
d = {"type": "join", **champs} # « déballe » champs dans un nouveau dictionnaire
Pourquoi ?
Pourquoi du JSON en texte plutôt qu'un format binaire ? Lisible à l'œil (on peut déboguer avec un print), et Python l'a de base. Pour 4 joueurs à 20 messages par seconde, la taille ne compte pas. Voir docs/02_multijoueur.md.
shared/protocol.py
shared/protocol.py
ErreurProtocole
Créez l'exception ErreurProtocole (comme ErreurParcours au chapitre 01). MESSAGES est fourni dans le début du fichier : lisez-le, c'est la liste de tout ce que le jeu se dit.
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de shared/protocol.py
"""Messages réseau : un dictionnaire JSON par ligne. [difficulté : ★☆☆]
TCP envoie un flot d'octets, pas des messages : un envoi peut arriver en deux
morceaux, ou deux envois collés. On termine donc chaque message par "\\n", et
le Decoupeur garde les morceaux jusqu'à avoir une ligne complète.
"""
import json
MESSAGES = {
"join": ["nom"], # client -> serveur (+ "balle" : nom de sa texture)
"welcome": ["id"], # serveur -> client : ton identifiant
"lobby": ["joueurs"], # serveur -> clients : [{id, nom, pret, bot, balle}]
"ready": [], # client -> serveur
"start_hole": ["trou", "total"], # serveur -> clients : numéro (1, 2...)
"ball": ["pos", "etat", "coups"], # client -> serveur, ~20 fois/s
"balls": ["balles", "temps"], # serveur -> clients, ~20 fois/s (+ "premier")
"shot": [], # client -> serveur : un coup joué
"holed": ["coups"], # client -> serveur : balle dans le trou
"scores": ["scores", "pars"], # serveur -> clients : {id: {nom, coups: [...]}}, [par...]
"end": ["classement"], # serveur -> clients : [{nom, total, rang, bilan}] trié
}
Prototype(s)
class ErreurProtocole(Exception):
pass
Code example(s)
from shared.protocol import ErreurProtocole, MESSAGES
print(issubclass(ErreurProtocole, Exception))
print(sorted(MESSAGES))
print(MESSAGES["ball"])
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/protocol.py
message
Construisez un message : {"type": type_message, **champs}, en vérifiant qu'il est valide.
Étapes
Type inconnu (pas dans MESSAGES) : ErreurProtocole.
Pour chaque champ obligatoire de MESSAGES[type_message] absent de champs : ErreurProtocole dont le message contient le nom du champ.
Renvoyer le dictionnaire (les champs en plus sont acceptés).
Prototype(s)
def message(type_message, **champs):
"""Construit un message et vérifie qu'il ne manque aucun champ.
message("join", nom="Zoé") -> {"type": "join", "nom": "Zoé"}
"""
pass
Code example(s)
from shared.protocol import message, ErreurProtocole
print(message("join", nom="Zoé", balle="rayee"))
print(message("ready"))
for t, champs in [("join", {}), ("teleport", {})]:
try:
message(t, **champs)
except ErreurProtocole as e:
print("ErreurProtocole :", "nom" in str(e) or "teleport" in str(e))
from shared.protocol import encoder
print(encoder({"type": "join", "nom": "Zoé"}))
print(encoder({"type": "ready"}).endswith(b"\n"))
Output
b'{"type": "join", "nom": "Zo\xc3\xa9"}\n'
True
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/protocol.py
decoder
L'inverse : une ligne d'octets sans le \n → le message. Lever ErreurProtocole si ce n'est pas du JSON valide en UTF-8, si ce n'est pas un dictionnaire, ou si son type est inconnu.
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/protocol.py
Decoupeur.__init__
Un Decoupeur garde les octets reçus qui ne forment pas encore une ligne complète : un attribut self.tampon, vide au départ (b"").
Prototype(s)
class Decoupeur:
...
def __init__(self):
"""Un découpeur neuf : tampon vide (b"")."""
pass
Code example(s)
from shared.protocol import Decoupeur
print(Decoupeur().tampon)
Output
b''
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
shared/protocol.py
Decoupeur.ajouter
Lore
Les morceaux arrivent dans le désordre de leur taille, jamais dans le désordre de leur ordre. Il suffit de patienter.
Ajoutez les octets reçus au tampon, et renvoyez la liste des messages complets qu'il contient maintenant (souvent aucun, parfois plusieurs). Gardez la fin incomplète pour la prochaine fois.
Étapes
self.tampon += donnees ; messages = [].
Tant que le tampon contient b"\n" : couper au premier \n en ligne et reste.
Si ligne n'est pas vide (après strip()) : decoder(ligne) dans messages.
self.tampon = reste.
Renvoyer messages.
Hint(s)
Indice 1 Couper en deux
b"abc\ndef".split(b"\n", 1) donne [b"abc", b"def"].
Prototype(s)
class Decoupeur:
...
def ajouter(self, donnees):
"""Ajoute des octets reçus, renvoie la liste des messages complets (peut être vide)."""
pass
Code example(s)
from shared.protocol import Decoupeur
d = Decoupeur()
print(d.ajouter(b'{"type":"rea'))
print(d.ajouter(b'dy"}\n{"type":"sh'))
print(d.ajouter(b'ot"}\n'))
print(d.tampon)
print(d.ajouter(b'{"type":"ready"}\n\n{"type":"shot"}\n'))
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
Valider le chapitre
python tools/progression.py 8.
Pour aller plus loin (facultatif)
Mesurez la taille d'un message balls à 4 joueurs : combien d'octets par seconde ?
Questions de soutenance
Pourquoi un \n à la fin de chaque message ?
Pourquoi arrondir les positions ?
Chapitre 09
Le salon : le serveur sans réseau
QuiPersonne 3Semaines3-5Difficulté★★☆VersionV4
server/salon.py
Lore
Le serveur est l'arbitre : il sait qui est là, qui est prêt, quel trou on joue, combien de temps il reste, et qui gagne. Il ne voit pas le terrain, il ne touche pas les balles. Il compte, il chronomètre, il annonce.
Objectif
Écrire toute la logique du serveur sans toucher au réseau. Le réseau (server/server.py, fourni) appelle les méthodes du Salon et envoie ce qu'il prépare. On teste tout sans socket : salon.tick(160) fait passer 160 secondes en une ligne.
Cours · Préparer au lieu d'envoyer
un client se connecte et envoie join -> salon.ajouter_joueur(nom, balle)
un client envoie un message -> salon.recevoir(id, message)
un client se déconnecte -> salon.retirer_joueur(id)
20 fois par seconde -> salon.tick(dt)
après chacun de ces appels -> server.py envoie salon.prendre_sorties()
Le salon met ses messages dans self.sorties : une liste de (destinataire, message), destinataire
None = tout le monde.
Cours · Les phases
stateDiagram-v2
salon --> trou: tous les humains prêts
trou --> scores: tout le monde a fini, ou chrono à 0
scores --> trou: pause finie
scores --> fin: après le dernier trou
fin --> trou: tous prêts (on rejoue)
trou --> salon: plus aucun humain
Cours · Le chrono
Au début du trou : temps_restant = temps_limite (3 min). Dès que le premier joueur finit :
temps_restant = min(temps_restant, apres_premier) (30 s ; le min : on ne redonne jamais de temps). À 0, ceux qui
n'ont pas fini marquent COUPS_MAX. Le classement se fait au total des coups.
Pourquoi ?
Pourquoi séparer la logique (salon.py) du réseau (server.py) ? Les sockets et les threads rendent les bugs difficiles à reproduire. Une logique sans réseau se teste en millisecondes, scénario par scénario. Voir docs/02_multijoueur.md.
server/salon.py — l'état
server/salon.py
Joueur.__init__ (fourni, à lire)
Un joueur, humain ou bot, vu par le serveur. Recopiez-le et expliquez chaque attribut : qui le change, quand ?
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de server/salon.py
"""Logique du serveur, SANS réseau : salon, trou en cours, bots, chrono, scores. [difficulté : ★★☆]
On lui signale des événements (un joueur arrive, envoie un message, part,
le temps passe) et elle prépare les messages à envoyer dans self.sorties :
une liste de (destinataire, message), destinataire None = tout le monde.
server.py ne fait que brancher ça sur des sockets ; les tests l'utilisent
directement, sans réseau.
Phases : "salon" -> "trou" -> "scores" -> "trou" -> ... -> "fin"
Chrono d'un trou : temps_limite au départ ; dès que le PREMIER joueur a
fini, il ne reste plus que apres_premier secondes aux autres (si c'est moins).
Un joueur qui n'a pas fini à temps marque COUPS_MAX. Le classement se fait
au total des coups, jamais au temps.
"""
import random
from ai.bot_golfer import GolfeurBot
from shared import constants as C
from shared.balles import PAR_DEFAUT, lister_balles
from shared.course_loader import charger_trou, lister_trous
from shared.physics import nouvelle_balle, pas, tirer
from shared.protocol import arrondir, message
from shared.scores import classement, texte_bilan
Warning(s)
Le début du fichier importe GolfeurBot (chapitre 10, personne 4). S'il n'existe pas encore, demandez à la personne 4 de pousser cette version minimale de ai/bot_golfer.py, qu'elle complétera ensuite :
class GolfeurBot:
def __init__(self, niveau="moyen", rng=None):
self.niveau = niveau
Code fourni
Code fourni : à recopier tel quel
class Joueur:
...
def __init__(self, ident, nom, balle=PAR_DEFAUT, bot=None):
self.id = ident
self.nom = nom
self.balle = balle # nom de la texture de sa balle
self.bot = bot # un GolfeurBot, ou None pour un humain
self.pret = bot is not None # les bots sont toujours prêts
self.pos = [0, 0, 0]
self.etat = "arret"
self.coups = 0
self.fini = False # a fini le trou en cours
self.scores = [] # coups par trou joué
self.simulee = None # bots seulement : la balle simulée ici
self.attente = 0.0 # bots seulement : temps avant le prochain tir
server/salon.py
Salon.__init__ (fourni, à lire)
L'état de toute la partie. Recopiez-le. Questions pour le journal : que contient self.numeros ? À quoi sert self.index ? Pourquoi les bots sont-ils créés ici ?
Code fourni
Code fourni : à recopier tel quel
class Salon:
...
def __init__(self, nb_bots=0, niveau_bot="moyen", numeros=None,
temps_limite=C.TEMPS_LIMITE_TROU, apres_premier=C.TEMPS_APRES_PREMIER,
pause=C.PAUSE_ENTRE_TROUS):
self.numeros = numeros or list(range(1, len(lister_trous()) + 1))
self.temps_limite = temps_limite
self.apres_premier = apres_premier
self.pause = pause
self.joueurs = {} # id -> Joueur
self.sorties = []
self.phase = "salon"
self.index = -1 # position dans self.numeros du trou en cours
self.parcours = None
self.pars = [] # par de chaque trou joué
self.temps_restant = 0.0 # chrono du trou en cours
self.premier = None # nom du premier à avoir fini le trou en cours
self.chrono = 0.0 # temps passé dans la phase "scores"
self.compteur = 0
textures = lister_balles() or [PAR_DEFAUT]
for i in range(nb_bots):
ident = f"bot{i + 1}"
self.joueurs[ident] = Joueur(ident, f"Bot {i + 1}", random.choice(textures),
GolfeurBot(niveau_bot, random.Random(i)))
server/salon.py
Salon.envoyer
Préparez un message : ajoutez le couple (a, msg) à self.sorties. a est l'id du destinataire, None pour tout le monde.
Prototype(s)
class Salon:
...
def envoyer(self, msg, a=None):
"""Prépare un message : a = id du destinataire, None = tout le monde."""
pass
Code example(s)
from server.salon import Salon
s = Salon(numeros=[1])
s.envoyer({"type": "ready"})
s.envoyer({"type": "welcome", "id": "j1"}, "j1")
print(s.sorties)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py
Salon.prendre_sorties
Renvoyez la liste des messages en attente et videz-la (le réseau les envoie, ils ne doivent pas repartir une deuxième fois).
Prototype(s)
class Salon:
...
def prendre_sorties(self):
"""Renvoie les messages en attente et vide la liste."""
pass
Code example(s)
from server.salon import Salon
s = Salon(numeros=[1])
s.envoyer({"type": "ready"})
print(s.prendre_sorties())
print(s.prendre_sorties())
Output
[(None, {'type': 'ready'})]
[]
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py
Salon.humains
Renvoyez la liste des joueurs qui ne sont pas des bots (j.bot is None).
Prototype(s)
class Salon:
...
def humains(self):
"""La liste des joueurs qui ne sont pas des bots."""
pass
Code example(s)
from server.salon import Joueur, Salon
s = Salon(nb_bots=2, numeros=[1])
s.joueurs["j1"] = Joueur("j1", "Alice")
print([j.nom for j in s.humains()], len(s.joueurs))
Output
['Alice'] 3
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py
Salon.envoyer_lobby
Envoyez à tous un message lobby avec la liste des joueurs : pour chacun {"id", "nom", "pret", "bot" (booléen), "balle"}.
Prototype(s)
class Salon:
...
def envoyer_lobby(self):
"""Envoie à tous un message lobby : [{id, nom, pret, bot, balle}] pour chaque joueur."""
pass
Code example(s)
from server.salon import Joueur, Salon
s = Salon(numeros=[1])
s.joueurs["j1"] = Joueur("j1", "Alice", "rayee")
s.envoyer_lobby()
print(s.prendre_sorties())
Nom coupé à 16 caractères (l'identifiant si le nom est vide).
Arrivé en pleine partie (phase autre que "salon") : COUPS_MAX pour chaque trou déjà joué, et fini = True (il attend le trou suivant).
Le ranger dans self.joueurs ; welcome à lui seul ; le lobby à tous.
En phase "trou" ou "scores" : un start_hole pour lui seul (pour qu'il voie le trou).
Prototype(s)
class Salon:
...
def ajouter_joueur(self, nom, balle=PAR_DEFAUT):
"""Un client vient de se connecter. Renvoie son identifiant."""
pass
Code example(s)
from server.salon import Salon
s = Salon(numeros=[1, 2], pause=0)
print(s.ajouter_joueur("Alice", "rayee"), s.ajouter_joueur("Bernadette-Gabrielle"))
for dest, m in s.prendre_sorties():
print(dest, m["type"], m.get("joueurs", m.get("id")))
print(s.joueurs["j2"].nom)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py
Salon.retour_salon
Retour à la phase "salon" : index = -1, plus de pars ; pour chaque joueur, plus de scores, pas fini, et prêt seulement si c'est un bot.
Prototype(s)
class Salon:
...
def retour_salon(self):
"""Retour à la phase salon : on efface les scores et les pars, les humains ne sont plus prêts."""
pass
server/salon.py
Salon.retirer_joueur
Un client s'est déconnecté : l'enlever (sans erreur s'il n'existe plus) ; s'il ne reste aucun humain, retour_salon() ; puis envoyer le lobby.
Prototype(s)
class Salon:
...
def retirer_joueur(self, ident):
"""Un client s'est déconnecté : il ne bloque plus rien."""
pass
Code example(s)
from server.salon import Salon
s = Salon(nb_bots=1, numeros=[1])
a = s.ajouter_joueur("Alice")
s.retirer_joueur(a)
s.retirer_joueur("j42")
print(list(s.joueurs), s.phase)
Output
['bot1'] salon
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py — les messages des joueurs
server/salon.py
Salon.joueur_pret
j est prêt : j.pret = True, lobby à tous. Puis, au salon ou en fin de partie, si tous les humains sont prêts : (retour au salon si on était en "fin") et trou_suivant().
Hint(s)
Indice 1 Tous
all(h.pret for h in self.humains()).
Prototype(s)
class Salon:
...
def joueur_pret(self, j):
"""j est prêt. Au salon ou en fin de partie, si TOUS les humains sont prêts, la partie (re)commence."""
pass
server/salon.py
Salon.joueur_fini
j a fini le trou : ses coups (plafonnés à COUPS_MAX), fini = True. Si c'est le premier à finir (self.premier vaut None) : noter son nom, et réduire le chrono (voir le cours).
Prototype(s)
class Salon:
...
def joueur_fini(self, j, coups):
"""j a fini le trou. Le premier qui finit réduit le chrono des autres."""
pass
server/salon.py
Salon.recevoir
Un message arrive du joueur ident (ignorez un joueur inconnu). Selon son type :
Étapes
"ready" : joueur_pret(j).
"ball", en phase "trou" : noter pos, etat, coups dans le joueur.
"holed", en phase "trou", s'il n'a pas déjà fini : joueur_fini(j, coups).
Prototype(s)
class Salon:
...
def recevoir(self, ident, msg):
"""Un message est arrivé du joueur ident."""
pass
server/salon.py — le déroulement
server/salon.py
Salon.trou_suivant
Passez au trou suivant.
Étapes
self.index += 1 ; après le dernier : fin_de_partie() et return.
Phase "trou" ; charger le parcours ; ajouter son par à self.pars.
Chrono à temps_limite ; premier = None.
Chaque joueur : pas fini, 0 coup, à l'arrêt. Chaque bot : une balle neuve (nouvelle_balle) dans j.simulee, attente = 1.5, pos = sa position arrondie.
Envoyer start_hole à tous (numéro du trou et nombre total).
Prototype(s)
class Salon:
...
def trou_suivant(self):
"""Passe au trou suivant (ou à la fin de partie après le dernier) : charge le parcours,
note son par, remet le chrono à temps_limite, remet les joueurs à zéro, place les balles des bots,
envoie start_hole."""
pass
server/salon.py
Salon.fin_du_trou
Notez le score de chacun (ses coups s'il a fini, sinon COUPS_MAX), envoyez scores ({id: {"nom", "coups"}} et les pars), passez en phase "scores" avec chrono = 0.
Prototype(s)
class Salon:
...
def fin_du_trou(self):
"""Note le score de chacun (COUPS_MAX s'il n'a pas fini), envoie scores (avec les pars),
passe en phase scores."""
pass
server/salon.py
Salon.fin_de_partie
Phase "fin". Envoyez end avec le classement (chapitre 02) des joueurs, chacun avec son bilan (texte_bilan). Les humains ne sont plus prêts (pour rejouer, il faudra le redire).
Prototype(s)
class Salon:
...
def fin_de_partie(self):
"""Phase fin : envoie end avec le classement (au total des coups, avec le bilan des scores
spéciaux de chacun). Les humains ne sont plus prêts (pour rejouer)."""
pass
server/salon.py
Salon.etat_balles
Renvoyez le contenu du message balls : pour chaque joueur, {nom, pos, etat, coups, fini, bot, balle} (bot : un booléen).
Prototype(s)
class Salon:
...
def etat_balles(self):
"""{id: {nom, pos, etat, coups, fini, bot, balle}} pour chaque joueur : le contenu du message balls."""
pass
Code example(s)
from server.salon import Salon
s = Salon(numeros=[1])
s.ajouter_joueur("Alice", "rayee")
print(s.etat_balles())
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py
Salon.tick
Le temps avance de dt secondes (environ 20 fois par seconde).
Étapes
Phase "trou" : décompter le chrono ; (les bots au chapitre 10 : pour l'instant, rien) ; envoyer balls à tous (etat_balles(), le temps arrondi à 0,1 et jamais négatif, premier) ; si tout le monde a fini ou si le temps est écoulé : fin_du_trou().
Ouvrez tests/test_server.py, par exemple test_le_premier_qui_finit_reduit_le_chrono : c'est l'histoire que votre code doit vivre.
Prototype(s)
class Salon:
...
def tick(self, dt):
"""Le temps avance de dt secondes (appelé ~20 fois par seconde)."""
pass
Code example(s)
from server.salon import Salon
from shared.protocol import message
s = Salon(numeros=[1, 2], temps_limite=180, apres_premier=30, pause=0)
a, b = s.ajouter_joueur("Alice"), s.ajouter_joueur("Bob")
s.recevoir(a, message("ready")); s.recevoir(b, message("ready"))
s.tick(10)
print(s.phase, s.temps_restant)
s.recevoir(a, message("holed", coups=1))
print(s.temps_restant, s.premier)
s.tick(31)
print(s.phase, s.joueurs[a].scores, s.joueurs[b].scores)
s.tick(0.05)
s.recevoir(a, message("holed", coups=3)); s.recevoir(b, message("holed", coups=2))
s.tick(0.05); s.tick(0.05)
fin = [m for _, m in s.prendre_sorties() if m["type"] == "end"][0]
for c in fin["classement"]:
print(c["rang"], c["nom"], c["total"], repr(c["bilan"]))
Output
trou 170
30 Alice
scores [1] [10]
1 Alice 4 '1 trou en un'
2 Bob 12 '1 birdie'
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
Vérification
Avec le réseau fourni, sans jeu, dans deux terminaux : python server/server.py puis python tools/fake_client.py --nombre 3 --coupure 2 : la partie va au bout malgré la déconnexion.
Valider le chapitre
python tools/progression.py 9 (les tests avec bots attendent le chapitre 10).
Pour aller plus loin (facultatif)
Un joueur qui n'a pas fini marque ses coups + 2 au lieu de COUPS_MAX : changez aussi le test, et défendez ce choix.
Questions de soutenance
Pourquoi séparer la logique du réseau ?
Que se passe-t-il si un joueur se déconnecte en plein trou ?
Pourquoi min(temps_restant, apres_premier) et pas apres_premier ?
Chapitre 10
Le golfeur bot
QuiPersonne 4Semaines5-7Difficulté★★☆VersionV5
ai/bot_golfer.pyserver/salon.py
Lore
Le parc a besoin d'adversaires quand les joueurs manquent. Pas des tricheurs : des golfeurs qui regardent le parcours, imaginent quarante tirs dans leur tête, gardent le meilleur, et tremblent un peu en frappant, plus ou moins selon leur niveau.
Objectif
Un adversaire qui joue seul, en trois niveaux, avec la même physique que les joueurs. Il vous faut : la physique complète (chapitres 04, 06, 07) et charger_trou.
Cours · Chercher par essais
Le bot ne « calcule » pas le bon tir : il en essaie beaucoup en secret avec simuler_tir et garde celui qui a la
meilleure note. C'est une recherche par force brute : simple, robuste, et assez rapide parce que la grille est petite.
Cible : le trou s'il est visible en ligne droite, sinon le point de passage (rose) visible le plus proche du trou.
Essais : 5 angles autour de la direction de la cible × 8 puissances = 40 simulations.
Note : dans le trou -1000, à l'eau ou dans le vide +1000, sinon la distance à la cible. Plus petit = meilleur.
Erreur : selon le niveau (C.NIVEAUX_BOT), un peu de hasard sur l'angle et la puissance.
Cours · Le hasard reproductible
random.Random(42) est un générateur de hasard à soi, avec une graine : il donne toujours la même suite de nombres. Les tests du
bot sont donc reproductibles : le même bot rate toujours au même endroit.
Cours · atan2
math.atan2(dz, dx) donne l'angle (en radians) du vecteur (dx, dz), dans le bon quadrant, même si dx
vaut 0. math.atan(dz / dx), lui, se trompe de côté une fois sur deux et plante si dx = 0.
Pourquoi ?
Pourquoi le bot tourne-t-il sur le serveur, pas chez un joueur ? Le serveur est le seul qui existe pendant toute la partie, et il n'a pas de fenêtre à afficher : il peut réfléchir sans faire saccader l'image de personne. Voir docs/05_ia.md.
ai/bot_golfer.py — voir le parcours
ai/bot_golfer.py
distance
Renvoyez la distance entre deux points (x, z) (Pythagore).
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de ai/bot_golfer.py
"""Le golfeur bot : il choisit direction + puissance en simulant des tirs. [difficulté : ★★☆]
1. Cible : le trou s'il est visible en ligne droite, sinon le point de
passage (rose) visible le plus proche du trou.
2. Simuler en secret des tirs vers la cible (physics.simuler_tir), avec
plusieurs angles et plusieurs puissances.
3. Garder le tir qui finit le plus près de la cible.
4. Ajouter une erreur aléatoire selon le niveau : facile, moyen, fort.
"""
import math
import random
from shared import constants as C
from shared.physics import simuler_tir
PUISSANCES = [0.12, 0.2, 0.3, 0.42, 0.55, 0.7, 0.85, 1.0]
DECALAGES_ANGLE = [-8, -4, 0, 4, 8]
Prototype(s)
def distance(a, b):
"""Distance entre deux points (x, z)."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py
ligne_libre
Lore
Avant de viser, on regarde : y a-t-il un mur, de l'eau, du vide entre la balle et la cible ?
Renvoyez True si le segment de a à b ne passe sur aucune case de mur, d'eau ou de vide.
Étapes
Le nombre d'intervalles : n = max(1, int(distance(a, b) / 0.2)) (un point tous les 0,2).
Pour i de 0 à ninclus : le point a + (b - a) × i / n (en x et en z).
Si sa case est "mur", "eau" ou "vide" : False.
Sinon, à la fin : True.
Prototype(s)
def ligne_libre(parcours, a, b):
"""Vrai si le segment a -> b (points (x, z)) ne traverse ni mur, ni eau, ni vide.
On regarde des points tous les 0.2 le long du segment.
"""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py
GolfeurBot.__init__
Gardez le niveau (ValueError s'il n'est pas dans C.NIVEAUX_BOT) et le générateur de hasard : self.rng = rng or random.Random().
Prototype(s)
class GolfeurBot:
...
def __init__(self, niveau="moyen", rng=None):
"""niveau : 'facile', 'moyen' ou 'fort' (sinon ValueError). rng : un random.Random (pour des tests reproductibles)."""
pass
Code example(s)
import random
from ai.bot_golfer import GolfeurBot
b = GolfeurBot("fort", random.Random(1))
print(b.niveau, round(b.rng.random(), 4))
print(GolfeurBot().niveau)
try:
GolfeurBot("expert")
except ValueError:
print("ValueError")
Output
fort 0.1344
moyen
ValueError
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py
GolfeurBot.choisir_cible
Renvoyez le point (x, z) que le bot vise.
Étapes
Le trou visible (ligne_libre de la balle au centre du trou) : le centre du trou.
Sinon, parmi les points de passage (leur centre) visibles et plus proches du trou que la balle (pour ne pas revenir en arrière) : le plus proche du trou.
Aucun : le trou quand même.
Prototype(s)
class GolfeurBot:
...
def choisir_cible(self, parcours, balle):
"""(x, z) visé : le trou s'il est visible, sinon le meilleur point de passage."""
pass
Code example(s)
from ai.bot_golfer import GolfeurBot
from shared.physics import nouvelle_balle
p = parcours_ascii(["#######", "#O..p.#", "####..#", "#...S.#", "#######"])
print(GolfeurBot().choisir_cible(p, nouvelle_balle(p)), p.centre(p.trou))
p2 = parcours_ascii(["#####", "#S.O#", "#####"])
print(GolfeurBot().choisir_cible(p2, nouvelle_balle(p2)))
Output
(4.5, 3.5) (1.5, 3.5)
(3.5, 1.5)
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py — choisir un tir
ai/bot_golfer.py
GolfeurBot.note
Notez un tir simulé (fin : la balle après le tir) : -1000.0 dans le trou, 1000.0 si elle est tombée à l'eau ou dans le vide, sinon la distance de fin à la cible. Plus petit = meilleur.
Hint(s)
Indice 1 Tombée à l'eau ?
Un tir normal ajoute 1 coup. L'eau et le vide en ajoutent un de plus : fin.coups > balle.coups + 1.
Prototype(s)
class GolfeurBot:
...
def note(self, balle, fin, cible):
"""Note d'un tir simulé : plus petit = meilleur."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py
GolfeurBot.essayer
Simulez chaque angle de angles avec chaque puissance de PUISSANCES, notez chaque tir, et renvoyez ((angle, puissance), note) du meilleur (la plus petite note).
Étapes
meilleur, note_min = None, math.inf.
Double boucle angles × puissances : fin, _, _ = simuler_tir(parcours, balle, angle, p), puis sa note.
Note plus petite que note_min : on garde ce tir.
Prototype(s)
class GolfeurBot:
...
def essayer(self, parcours, balle, cible, angles):
"""Simule chaque angle avec chaque puissance de PUISSANCES. Renvoie ((angle, puissance), note) du meilleur."""
pass
Code example(s)
from ai.bot_golfer import GolfeurBot
from shared.physics import nouvelle_balle
p = parcours_ascii(["#########", "#S.....O#", "#########"])
bot = GolfeurBot()
tir, note = bot.essayer(p, nouvelle_balle(p), p.centre(p.trou), [0, 90])
print(tir, note)
Output
(0, 0.55) -1000.0
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py
GolfeurBot.meilleur_tir
Renvoyez le tir parfait (angle, puissance), sans erreur.
Étapes
La cible ; l'angle vers elle (angle_vers).
essayer avec les angles angle_cible + d pour chaque d de DECALAGES_ANGLE.
Coincé : si la meilleure note n'est pas au moins 0,5 plus petite que la distance actuelle à la cible (aucun tir ne rapproche), essayer toutes les directions de 15° en 15° (range(0, 360, 15)), et garder ce résultat s'il est meilleur.
Prototype(s)
class GolfeurBot:
...
def meilleur_tir(self, parcours, balle):
"""Le tir parfait (angle_deg, puissance), sans erreur."""
pass
Code example(s)
from ai.bot_golfer import GolfeurBot
from shared.physics import nouvelle_balle
p = parcours_ascii(["#########", "#S.....O#", "#########"])
angle, puissance = GolfeurBot().meilleur_tir(p, nouvelle_balle(p))
print(round(angle, 1), puissance)
Output
-8.0 0.7
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
ai/bot_golfer.py
GolfeurBot.choisir_tir
Le tir réellement joué : le meilleur, plus l'erreur du niveau.
Renvoyer l'angle et la puissance bornée entre 0,05 et 1.
Danger(s)
Utilisez self.rng, jamais le module random directement : sinon les tests ne sont plus reproductibles.
Prototype(s)
class GolfeurBot:
...
def choisir_tir(self, parcours, balle):
"""Le tir joué (angle_deg, puissance), avec l'erreur du niveau."""
pass
Code example(s)
from ai.bot_golfer import GolfeurBot
from shared.course_loader import charger_trou
from shared.physics import nouvelle_balle, simuler_tir
p = charger_trou(1)
for niveau in ("fort", "facile"):
bot, balle = GolfeurBot(niveau, random.Random(1)), nouvelle_balle(p)
while balle.etat != "trou" and balle.coups < 10:
angle, puissance = bot.choisir_tir(p, balle)
balle, _, _ = simuler_tir(p, balle, angle, puissance)
print(niveau, round(angle, 1), round(puissance, 2), "->", (round(balle.x, 1), round(balle.z, 1)), balle.etat)
Output
fort 88.9 0.87 -> (4.7, 9.3) arret
fort 93.2 0.3 -> (4.5, 11.5) trou
facile 82.7 0.97 -> (6.4, 7.1) arret
facile 115.3 0.5 -> (4.5, 11.2) arret
facile 86.8 0.12 -> (4.5, 11.5) trou
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
server/salon.py — les bots dans la partie
server/salon.py
Salon.jouer_bot
Faites avancer le bot j de dt secondes. Rien s'il a fini.
Étapes
Balle (j.simulee) à l'arrêt avec COUPS_MAX coups : joueur_fini, return.
Balle à l'arrêt : j.attente -= dt ; quand elle passe à 0 : choisir un tir (j.bot.choisir_tir), le jouer (tirer), j.attente = 1.5 (pour que les humains voient le bot réfléchir).
Balle qui roule : round(dt / C.PAS_PHYSIQUE) pas de physique ; dans le trou : joueur_fini.
Toujours, à la fin : recopier la position (arrondir), l'état et les coups de la balle dans j.
Prototype(s)
class Salon:
...
def jouer_bot(self, j, dt):
"""Fait avancer un bot : attendre, choisir un tir, puis simuler la balle."""
pass
server/salon.py
Salon.tick : faire jouer les bots
Dans tick, en phase "trou", juste après le chrono : jouer_bot(j, dt) pour chaque joueur qui a un bot.
Prototype(s)
class Salon:
...
def tick(self, dt):
"""Le temps avance de dt secondes (appelé ~20 fois par seconde)."""
pass
Vérification
python tools/bot_bench.py --parties 20 : le bot fort finit toujours en moins de 10 coups. python tools/check_course.py : tous les trous sont jouables.
Valider le chapitre
python tools/progression.py 10.
Pour aller plus loin (facultatif)
Arrêter une simulation dès que la balle tombe à l'eau. Un bot qui vise les tremplins.
Questions de soutenance
Le bot triche-t-il ? (Il voit tout le parcours et connaît la physique exacte.)
Comment le bot sert-il aussi à vérifier que les trous sont bien dessinés ?
Chapitre 11
Jouer en réseau
QuiPersonne 3, puis tousSemaines6-9Difficulté★★☆VersionV4
client/net_client.pyclient/hud.pyclient/main.py
Lore
Le grand soir : quatre écrans, une seule partie. Chacun voit les balles des autres glisser sur le même parcours, le chrono tombe à trente secondes quand le premier rentre sa balle, et quand quelqu'un ferme son ordinateur en plein trou, la partie continue.
Objectif
python jouer.py --solo lance un serveur local avec des bots, et on joue contre eux. Puis --heberger sur un PC et --rejoindre <ip> sur les autres. Il vous faut : chapitres 05, 08, 09.
Cours · Client, serveur, socket
Le serveur écoute sur un port (5555). Un client ouvre une connexion TCP vers l'adresse IP du serveur et ce port :
socket.create_connection((hote, port)). Ensuite, chacun envoie (sendall) et reçoit (recv) des octets.
recvattend qu'il arrive quelque chose : on ne peut pas l'appeler dans la boucle du jeu, sinon l'image se fige.
Cours · Thread et file d'attente
thread réseau thread du jeu (Ursina)
socket --> recv() --> Decoupeur --> queue.Queue --> messages() dans Jeu.update()
Un thread est un deuxième fil d'exécution dans le même programme : il peut attendre sur recv pendant que le jeu continue.
Il ne doit jamais toucher aux objets Ursina (plantages aléatoires) : il dépose les messages dans une queue.Queue, et le jeu les
pioche à chaque image, sans attendre.
Cours · Qui décide de quoi
Chaque client simule sa propre balle (sa physique, ses collisions) et envoie sa position 20 fois par seconde. Le serveur relaie les
positions, tient le chrono et les scores. Simple, réactif, mais le serveur fait confiance aux clients : on peut tricher. C'est une
limite assumée, à dire en soutenance.
Pourquoi ?
Tout le raisonnement (pourquoi TCP, pourquoi le client fait sa physique, pourquoi 20 messages par seconde, pourquoi un processus séparé pour le serveur en solo) est dans docs/02_multijoueur.md. Lisez-le à quatre avant ce chapitre.
client/net_client.py — le client réseau (personne 3)
client/net_client.py
ClientReseau.connecter
Ouvrez une connexion TCP vers (hote, port), en réessayant pendant delai secondes (en solo, le serveur met un instant à démarrer). Renvoyez le socket.
Étapes
fin = time.time() + delai.
En boucle : socket.create_connection((hote, port), timeout=2).
Réussi : settimeout(None) (plus de limite pour la suite), l'option TCP_NODELAY (voir server/server.py), renvoyer le socket.
OSError : si le temps est dépassé, relever l'erreur (raise) ; sinon attendre 0,2 s et réessayer.
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de client/net_client.py
"""Connexion au serveur, côté client. Python pur, sans Ursina. [difficulté : ★★☆]
Un thread lit le socket et dépose les messages reçus dans self.recus
(une queue.Queue). La boucle update() du jeu vide cette file à chaque image :
on ne touche JAMAIS aux objets Ursina depuis le thread réseau.
"""
import queue
import socket
import threading
import time
from shared import constants as C
from shared.protocol import Decoupeur, ErreurProtocole, encoder, message
Information
@staticmethod : une méthode qui n'utilise pas self.
Prototype(s)
class ClientReseau:
...
@staticmethod
def connecter(hote, port, delai):
"""Le serveur peut mettre un instant à démarrer : on réessaie."""
pass
client/net_client.py
ClientReseau.__init__
Créez la file self.recus (queue.Queue()), connectez-vous, self.connecte = True, démarrez le thread de lecture (threading.Thread(target=self.lire, daemon=True).start()), et envoyez join avec le nom et la balle.
Information
daemon=True : le thread s'arrête tout seul quand le programme se ferme.
Prototype(s)
class ClientReseau:
...
def __init__(self, hote="127.0.0.1", port=C.PORT, nom="Joueur", balle="blanche", delai=5.0):
"""Se connecte (en réessayant jusqu'à delai secondes) et envoie « join » avec sa balle."""
pass
client/net_client.py
ClientReseau.lire (le thread)
Le thread réseau : recevoir, découper, déposer dans la file. Jusqu'à ce que la connexion se ferme.
Chaque message de decoupeur.ajouter(donnees) : self.recus.put(msg).
Le tout dans un try : OSError ou ErreurProtocole arrêtent aussi la lecture.
À la fin : self.connecte = False et un message local {"type": "deconnecte"} dans la file.
Prototype(s)
class ClientReseau:
...
def lire(self):
"""Thread réseau : reçoit, découpe, dépose dans la file."""
pass
client/net_client.py
ClientReseau.envoyer
Envoyez un message (sendall(encoder(msg))). Ne plantez jamais : rien si on n'est plus connecté, et une OSError passe self.connecte à False.
Prototype(s)
class ClientReseau:
...
def envoyer(self, msg):
"""Envoie un message (dict). Ne plante pas si la connexion est coupée."""
pass
client/net_client.py
ClientReseau.messages
Renvoyez tous les messages en attente dans la file, sans jamais attendre (une liste vide s'il n'y en a pas).
Hint(s)
Indice 1 Lire sans bloquer
sortie = []
while not self.recus.empty():
sortie.append(self.recus.get())
return sortie
Prototype(s)
class ClientReseau:
...
def messages(self):
"""Tous les messages reçus depuis le dernier appel (sans attendre)."""
pass
client/net_client.py
ClientReseau.attendre
Pour les tests et fake_client : attendez un message du type demandé, au plus delai secondes ; renvoyez-le, ou None. Les messages d'autres types sont jetés.
Hint(s)
Indice 1 Attendre avec une limite
self.recus.get(timeout=t) attend au plus t secondes et lève queue.Empty si rien n'arrive.
Prototype(s)
class ClientReseau:
...
def attendre(self, type_message, delai=5.0):
"""Attend un message d'un type donné (outils et tests). None si trop long."""
pass
client/net_client.py
ClientReseau.fermer
Fermez proprement : connecte = False, shutdown(socket.SHUT_RDWR) (dans un try : il peut échouer si l'autre côté est déjà parti), puis close(). Le thread de lecture s'arrête tout seul.
Information
Cet exemple ouvre un vrai serveur sur votre PC : il teste les sept méthodes ensemble. (Le test test_vrai_reseau_trois_faux_clients de la moulinette fait pareil avec trois clients.)
Prototype(s)
class ClientReseau:
...
def fermer(self):
"""Ferme la connexion proprement (le thread de lecture s'arrête tout seul)."""
pass
Code example(s)
from server.salon import Salon
from server.server import ServeurTCP
from client.net_client import ClientReseau
from shared.protocol import message
serveur = ServeurTCP(Salon(numeros=[1]), "127.0.0.1", 0).demarrer()
c = ClientReseau("127.0.0.1", serveur.port, "Zoé", "rayee")
print(c.attendre("welcome"))
print(c.attendre("lobby")["joueurs"])
c.envoyer(message("ready"))
print(c.attendre("start_hole"))
c.fermer(); time.sleep(0.3)
serveur.arreter()
client/main.py — lancer une partie en ligne (personne 3)
client/main.py
Les imports réseau
Ajoutez ces lignes au début de client/main.py.
Code fourni
Code fourni : à recopier tel quel
# Dans le début de client/main.py, ajoutez :
import atexit
import socket
import subprocess
from client.hud import Hud, texte_salon # noqa: E402 (remplace « from client.hud import Hud »)
from client.net_client import ClientReseau # noqa: E402
from shared.protocol import arrondir, message # noqa: E402
COULEURS_JOUEURS = [color.azure, color.orange, color.magenta, color.lime, color.yellow, color.cyan]
client/main.py
ip_locale, port_libre, lancer_serveur (fournies, à lire)
Recopiez-les au-dessus de class Cubogolf et expliquez chaque ligne à un coéquipier. lancer_serveur démarre server/server.py dans un autre processus.
Pourquoi ?
Pourquoi un autre processus et pas un thread ? Python n'exécute qu'un thread à la fois (le « GIL ») : le bot qui réfléchit figerait l'image. Mesuré sur ce projet : les pires images passent de 43 ms à 16 ms avec un processus séparé.
Code fourni
Code fourni : à recopier tel quel
def ip_locale():
"""Adresse IP de ce PC sur le réseau local (à donner aux autres joueurs)."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1)) # n'envoie rien, choisit juste la carte réseau
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def port_libre():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def lancer_serveur(nb_bots, niveau, public):
"""Démarre server/server.py dans un AUTRE processus. Renvoie (port, processus).
Un autre processus = un autre cœur du processeur : le bot peut réfléchir
sans faire saccader le jeu. Il est arrêté au retour au menu ou à la fermeture.
"""
hote, port = ("0.0.0.0", C.PORT) if public else ("127.0.0.1", port_libre())
processus = subprocess.Popen([sys.executable, str(C.RACINE / "server" / "server.py"),
"--hote", hote, "--port", str(port),
"--bots", str(nb_bots), "--niveau", niveau])
atexit.register(processus.terminate)
return port, processus
client/main.py
Cubogolf.arreter_serveur
S'il y a un serveur local (self.serveur) : terminate(), puis self.serveur = None.
Prototype(s)
class Cubogolf(Entity):
...
def arreter_serveur(self):
"""Arrête le serveur local s'il y en a un."""
pass
client/main.py
Cubogolf.lancer_en_ligne
Solo, héberger ou rejoindre : se connecter, et créer la partie en mode "reseau".
Étapes
port = C.PORT ; hote = celui donné, sinon le réglage "ip".
Solo ou héberger : port, self.serveur = lancer_serveur(bots, niveau, public=(mode == "heberger")) ; hote = "127.0.0.1".
reseau = ClientReseau(hote, port, nom, balle) dans un try. OSError : arrêter le serveur, revenir au menu, afficher « Impossible de joindre ... » en rouge, return.
self.jeu = Jeu("reseau", reseau=reseau, ...).
Solo : envoyer ready tout de suite. Héberger : annoncer l'IP à donner aux autres (ip_locale()).
Prototype(s)
class Cubogolf(Entity):
...
def lancer_en_ligne(self, mode, hote=None):
"""Solo, héberger ou rejoindre : (démarrer le serveur local), se connecter, créer la partie."""
pass
client/main.py
Cubogolf.lancer (complète)
self.montrer(None), puis lancer_local pour l'entraînement et la visite, lancer_en_ligne pour le reste.
Prototype(s)
class Cubogolf(Entity):
...
def lancer(self, mode, hote=None, trou=None):
"""mode : solo, heberger, rejoindre, entrainement, visite.
hote et trou remplacent les réglages pour cette fois (raccourcis de la ligne de commande).
"""
pass
client/main.py et client/hud.py — le jeu en ligne (tous, semaine 8)
client/hud.py
texte_salon
La liste du salon, une ligne par joueur : [pret] ou [....], un espace, le nom, et " (bot)" pour un bot.
Prototype(s)
def texte_salon(joueurs):
"""joueurs [{nom, pret, bot}] -> liste lisible du salon."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/main.py
Jeu.tirer : prévenir le serveur
Complétez Jeu.tirer : en ligne (self.reseau), envoyer message("shot").
Prototype(s)
class Jeu(Entity):
...
def tirer(self, angle, puissance):
"""Tire la balle, met à jour le HUD, prévient le serveur (shot) en ligne."""
pass
client/main.py
Jeu.envoyer_position
Envoyez sa balle au serveur (message ball : position arrondie, état, coups), C.FREQUENCE_RESEAU fois par seconde — pas à chaque image. Appelez-la à la fin de physique, en ligne seulement.
class Jeu(Entity):
...
def envoyer_position(self, dt):
"""Envoie sa balle au serveur (message ball), FREQUENCE_RESEAU fois par seconde, pas à chaque image."""
pass
class Jeu(Entity):
...
def physique(self):
"""À chaque image : les pas de physique, le dessin de la balle, les annonces, la fin du trou,
l'envoi de sa position en ligne."""
pass
client/main.py
Jeu.trou_fini : prévenir le serveur
Complétez trou_fini : en ligne, envoyer holed avec ses coups (au plus COUPS_MAX) au lieu de charger le trou suivant (c'est le serveur qui décide quand on change de trou).
Prototype(s)
class Jeu(Entity):
...
def trou_fini(self):
"""Le trou est fini (balle dans le trou ou COUPS_MAX) : annonce le score spécial ;
en ligne envoie holed, en entraînement charge le trou suivant après 2,5 s."""
pass
client/main.py
Jeu.creer_autre
Créez la sphère d'un autre joueur, à position : sa texture (texture_balle(info.get("balle", "blanche"))), la taille d'une balle, et son nom au-dessus, d'une couleur de COULEURS_JOUEURS (une par joueur, en boucle). Rangez-la dans self.autres[ident].
Information
Un texte qui fait toujours face à la caméra : Text(parent=e, text=nom, billboard=True, origin=(0, 0), position=(0, 2.5, 0), scale=8, color=c).
Prototype(s)
class Jeu(Entity):
...
def creer_autre(self, ident, info, position):
"""La sphère d'un autre joueur : sa texture, son nom au-dessus, une couleur par joueur."""
pass
client/main.py
Jeu.maj_autres
Mettez à jour les balles des autres avec le contenu d'un message balls.
Étapes
Pour chaque (ident, info) : sauter sa propre balle (self.mon_id).
La position où dessiner : (x, y + C.BALLE_RAYON, z).
Inconnue : creer_autre. Puis noter la cible dans self.cibles_autres[ident].
La cacher si elle est dans le trou (enabled = info["etat"] != "trou").
Enfin, détruire (et oublier) les balles des joueurs qui ne sont plus dans le message.
Prototype(s)
class Jeu(Entity):
...
def maj_autres(self, balles):
"""Crée, met à jour ou supprime les balles des autres joueurs."""
pass
client/main.py
Jeu.recevoir (sans chrono ni scores)
Réagissez à un message du serveur, selon son type. Les scores et le chrono viendront au chapitre 13.
Étapes
welcome : self.mon_id = msg["id"].
lobby, tant qu'aucun trou n'est chargé : le panneau « Salon », la liste (texte_salon) et « Entrée : je suis prêt Échap : menu ».
start_hole : self.total = msg["total"], puis charger(msg["trou"]).
balls : self.sur_balls(msg) (exercice suivant).
deconnecte : le panneau « Connexion au serveur perdue. Échap : menu ».
Prototype(s)
class Jeu(Entity):
...
def recevoir(self, msg):
"""Réagit à un message du serveur : welcome, lobby, start_hole, balls (balles, chrono,
premier), scores, end, deconnecte."""
pass
client/main.py
Jeu.sur_balls (sans chrono)
Pour l'instant : self.maj_autres(msg["balles"]). Le chrono et l'annonce du premier viendront au chapitre 13.
Prototype(s)
class Jeu(Entity):
...
def sur_balls(self, msg):
"""Message balls (20 fois par seconde) : les balles des autres, le chrono, et l'annonce
quand un premier joueur vient de finir."""
pass
client/main.py
Jeu.maj_reseau et Jeu.update
maj_reseau() : traiter chaque message de self.reseau.messages() avec recevoir, puis faire glisser chaque balle des autres vers sa cible : e.position = lisser(position, cible, 15, time.dt). Dans update : en ligne, maj_reseau() en premier.
Information
Pourquoi glisser ? Les positions arrivent 20 fois par seconde, l'écran en affiche 60 ou plus : sans lissage, les balles des autres avancent par sauts.
Prototype(s)
class Jeu(Entity):
...
def maj_reseau(self):
"""Traite les messages reçus, puis fait glisser (et rouler) les balles des autres vers leur position reçue."""
pass
class Jeu(Entity):
...
def update(self):
"""À chaque image : lire les messages du réseau, faire glisser et rouler les balles des autres,
faire avancer la physique (sauf en pause hors ligne), afficher la jauge."""
pass
client/main.py
Jeu.input : Entrée = prêt
Dans Jeu.input : en ligne, la touche "enter" envoie message("ready").
Prototype(s)
class Jeu(Entity):
...
def input(self, touche):
"""Touches de la partie : Tab (scores), Entrée (prêt), N / P (visite), R (entraînement)."""
pass
Vérification
python jouer.py --solo : on joue contre les bots. Sur 2 PC du même réseau : --heberger sur l'un, --rejoindre <ip> sur l'autre. Un joueur ferme son jeu en plein trou : les autres continuent. Si le wifi de l'école bloque : le partage de connexion d'un téléphone.
Valider le chapitre
Jalon V4 : une partie complète à plusieurs PC. python tools/progression.py 11.
Pour aller plus loin (facultatif)
Afficher le « ping » (temps aller-retour d'un message). Un chat.
Questions de soutenance
Pourquoi une queue.Queue entre le thread réseau et le jeu ?
Le serveur fait confiance aux clients : comment tricher ? Comment l'empêcher, en théorie ?
Pourquoi chaque client simule-t-il sa balle, plutôt que le serveur ?
Chapitre 12
Menus, pause et réglages
QuiPersonne 2Semaines6-7Difficulté★★☆
client/menu.pyclient/main.py
Lore
Un vrai jeu s'ouvre sur un menu, se met en pause, se souvient de vous. Le parc veut une borne d'accueil : choisir son mode, sa balle, ses adversaires, et changer d'avis à tout moment sans perdre sa partie.
Objectif
python jouer.py ouvre un menu : Solo, Héberger, Rejoindre, Entraînement, Visite, Réglages, Quitter. Les réglages se changent à tout moment (menu, ou Échap en partie) et sont retrouvés au lancement suivant. Il vous faut : client/reglages.py (chapitre 02), le chapitre 05.
Cours · Fonctions de rappel et lambda
Un bouton reçoit une fonction à appeler au clic : Button(text="Solo", on_click=self.lancer_solo) (sans parenthèses : on
donne la fonction, on ne l'appelle pas). Pour passer un argument : on_click=lambda: controleur.lancer("solo").
Le piège : dans une boucle, lambda: self.cliquer(o["cle"]) lit o au moment du clic, quand la boucle est
finie : tous les boutons modifient la dernière option ! La solution propre : construire chaque ligne dans sa propre méthode
(ligne(o, y, maxi)) : chaque appel a ses propres variables, et chaque lambda garde les siennes.
Cours · Contrôleur et écrans
lancer(mode) Échap
Accueil --------------> partie (Jeu) <-------------> Pause ---> Retour au menu ---> Accueil
| |
+--> Réglages <--------------------------------------+
Un écran = une Entity sur camera.ui ; changer d'écran = détruire l'ancien. Les écrans ne lancent rien : ils appellent
le contrôleur (Cubogolf), seul à savoir lancer, mettre en pause, revenir au menu.
Pourquoi ?
Pourquoi l'écran Réglages lit-il OPTIONS au lieu d'avoir un bouton écrit à la main par réglage ? Ajouter un réglage devient une ligne de données, sans toucher à l'interface : moins de code, moins de bugs.
Warning(s)
Dans un texte Ursina, < et > sont des balises de mise en forme : un bouton "<" fait planter Ursina. D'où les boutons - et +.
client/menu.py — les écrans
client/menu.py
La classe Ecran (fournie, à lire)
Ecran : un fond, un titre, bouton() pour poser un bouton, boutons() pour en poser une colonne. Recopiez-la ; tous les écrans en héritent.
Début du fichier (fourni)
Le fichier commence ainsi : sa description, ses imports et ses constantes. Recopiez-le, puis écrivez les fonctions en dessous.
Début de client/menu.py
"""Les écrans du menu : accueil, réglages, pause. [difficulté : ★★☆]
Chaque écran est une Entity posée sur camera.ui ; on la détruit pour en
changer. Ils ne lancent rien eux-mêmes : ils appellent le « contrôleur »
(la classe Cubogolf de main.py), qui sait lancer une partie, mettre en
pause, revenir au menu...
Écran Réglages : il affiche reglages.OPTIONS ligne par ligne. Pour ajouter
un réglage, il suffit d'ajouter une ligne dans OPTIONS (et de l'utiliser).
"""
from ursina import Button, Entity, InputField, Text, application, camera, color, time
from client import reglages as R
from client.textures import texture_balle
from shared.course_loader import lister_trous
COULEUR_BOUTON = color.rgba32(30, 90, 50, 255)
Code fourni
Code fourni : à recopier tel quel
class Ecran(Entity):
"""Un fond sombre sur tout l'écran + des petites fonctions pour poser des éléments."""
def __init__(self, controleur, titre, transparent=False):
super().__init__(parent=camera.ui)
self.controleur = controleur
Entity(parent=self, model="quad", scale=(3, 2), z=1,
color=color.rgba32(10, 30, 20, 200 if transparent else 240))
Text(parent=self, text=titre, origin=(0, 0), y=0.38, scale=3)
def bouton(self, texte, y, action, largeur=0.5):
return Button(parent=self, text=texte, y=y, scale=(largeur, 0.065),
color=COULEUR_BOUTON, on_click=action)
def boutons(self, liste, y0=0.2, ecart=0.085):
"""liste = [(texte, action), ...] posés les uns sous les autres."""
for i, (texte, action) in enumerate(liste):
self.bouton(texte, y0 - i * ecart, action)
client/menu.py
MenuPrincipal.__init__
L'accueil : un bouton par mode, dont le texte rappelle les réglages utiles, puis Réglages et Quitter, et en bas le pseudo et le rendu.
Étapes
super().__init__(controleur, "CUBOGOLF").
Le nom du trou des réglages : lister_trous()[trou - 1]["nom"] (sans dépasser le nombre de trous).
self.boutons([...]) : « Solo (2 bots fort) » (resume_bots, chapitre 02), « Héberger une partie (...) », « Rejoindre 192.168.1.20 », « Entraînement (trou 2 : Le virage) », « Visite des trous », « Réglages », « Quitter ».
Chaque bouton appelle le contrôleur : lambda: controleur.lancer("solo")... ; Quitter : application.quit.
En bas : Text(parent=self, text=f"Joueur : ... Rendu : ...", origin=(0, 0), y=-0.43).
Prototype(s)
class MenuPrincipal(Ecran):
...
def __init__(self, controleur):
"""L'accueil : un bouton par mode (avec les réglages utiles dans le texte), Réglages, Quitter."""
pass
client/menu.py
EcranReglages.ligne
Posez la ligne de l'option o à la hauteur y.
Étapes
Le libellé à gauche : Text(parent=self, text=o["libelle"], x=-0.45, y=y, origin=(-0.5, 0)).
Type "texte" : un InputField (valeur actuelle, 20 caractères au plus) dont on_value_changed appelle self.taper(cle, champ.text).
Autres types : un bouton - (x = 0,05), la valeur (un Text en x = 0,2, gardé dans self.valeurs[cle]), un bouton + (x = 0,35). Les boutons appellent self.cliquer(cle, ±1, maxi).
Pour la balle : un aperçu, Entity(parent=self, model="sphere", x=0.5, y=y, z=-0.5, scale=0.09) dans self.apercu.
Prototype(s)
class EcranReglages(Ecran):
...
def ligne(self, o, y, maxi):
"""Une ligne de l'écran pour l'option o, à la hauteur y : son libellé, puis un champ de texte,
ou - valeur + (maxi : le maximum à donner à changer). Pour la balle, un aperçu."""
pass
client/menu.py
EcranReglages.__init__
Un fond transparent si on vient de la pause. self.depuis, self.valeurs = {}. Une ligne par option, de y = 0,27 en descendant de 0,058 (pour "trou", maxi = le nombre de trous, sinon None). Puis afficher() et un bouton Retour qui appelle controleur.fermer_reglages(depuis).
Prototype(s)
class EcranReglages(Ecran):
...
def __init__(self, controleur, depuis):
"""Une ligne par option de reglages.OPTIONS : un champ de texte, ou des boutons - et +
autour de la valeur. depuis = 'menu' ou 'pause' (où revenir). Un aperçu de la balle qui tourne."""
pass
client/menu.py
EcranReglages.afficher
Réécrivez chaque valeur (texte_valeur, chapitre 02) ; pour le trou, ajoutez « : nom du trou ». Mettez la texture de la balle choisie sur l'aperçu (texture_balle).
Prototype(s)
class EcranReglages(Ecran):
...
def afficher(self):
"""Réécrit toutes les valeurs affichées (et la texture de l'aperçu de la balle)."""
pass
client/menu.py
EcranReglages.update
Faites tourner l'aperçu de la balle : 60° par seconde (rotation_y, time.dt).
Prototype(s)
class EcranReglages(Ecran):
...
def update(self):
"""Fait tourner l'aperçu de la balle."""
pass
client/menu.py
EcranReglages.cliquer et taper
cliquer(cle, sens, maxi) : R.changer sur les réglages du contrôleur, afficher(), puis prévenir le contrôleur (reglages_changes()). taper(cle, texte) : ranger le texte dans les réglages et prévenir le contrôleur.
Prototype(s)
class EcranReglages(Ecran):
...
def cliquer(self, cle, sens, maxi):
"""Bouton - ou + : change l'option d'un cran, réaffiche, prévient le contrôleur."""
pass
class EcranReglages(Ecran):
...
def taper(self, cle, texte):
"""Un champ de texte a changé : on garde le texte et on prévient le contrôleur."""
pass
client/menu.py
MenuPause.__init__
Fond transparent ; Reprendre, Réglages (depuis la pause), Retour au menu, Quitter (à partir de y = 0,15) ; et si la partie est en ligne, la note « Partie en ligne : elle continue pendant la pause. ».
Prototype(s)
class MenuPause(Ecran):
...
def __init__(self, controleur, en_ligne):
"""Reprendre, Réglages, Retour au menu, Quitter (+ une note si la partie est en ligne)."""
pass
client/main.py — le contrôleur
client/main.py
L'import des écrans
Ajoutez cette ligne au début de client/main.py.
Code fourni
Code fourni : à recopier tel quel
# Dans le début de client/main.py, ajoutez :
from client.menu import EcranReglages, MenuPause, MenuPrincipal # noqa: E402
class Cubogolf(Entity):
...
def menu(self):
"""Affiche l'écran d'accueil."""
pass
client/main.py
Cubogolf.ouvrir_reglages et fermer_reglages
ouvrir_reglages(depuis) : montrer l'écran Réglages. fermer_reglages(depuis) : revenir à la pause si on venait de la pause, sinon à l'accueil.
Prototype(s)
class Cubogolf(Entity):
...
def ouvrir_reglages(self, depuis):
"""Affiche l'écran Réglages ; depuis = 'menu' ou 'pause'."""
pass
class Cubogolf(Entity):
...
def fermer_reglages(self, depuis):
"""Quitte les Réglages : retour à la pause ou à l'accueil, selon depuis."""
pass
client/main.py
Cubogolf.reglages_changes et appliquer (complète)
reglages_changes() : sauver (R.sauver) puis appliquer. appliquer() : en partie, changer aussi le rendu tout de suite (self.jeu.changer_rendu(self.cubes())).
Prototype(s)
class Cubogolf(Entity):
...
def reglages_changes(self):
"""Un réglage a changé : on sauvegarde et on applique."""
pass
class Cubogolf(Entity):
...
def appliquer(self):
"""Applique tout de suite ce qui peut l'être (le reste servira à la prochaine partie)."""
pass
client/main.py
Cubogolf.pause et reprendre
pause() : mettre la partie en pause et montrer le menu pause. reprendre() : plus d'écran, partie relancée.
Prototype(s)
class Cubogolf(Entity):
...
def pause(self):
"""Met la partie en pause et affiche le menu pause."""
pass
class Cubogolf(Entity):
...
def reprendre(self):
"""Ferme le menu pause et relance la partie."""
pass
client/main.py
Cubogolf.retour_menu
Quittez la partie s'il y en a une (self.jeu.quitter(), puis self.jeu = None), arrêtez le serveur local, affichez l'accueil.
Prototype(s)
class Cubogolf(Entity):
...
def retour_menu(self):
"""Quitte la partie (et le serveur local), affiche l'accueil."""
pass
client/main.py
Cubogolf.input : Échap
Seule la touche "escape" compte.
Étapes
Pas de partie (on est au menu) : rien.
Partie sans écran par-dessus : pause().
Écran Réglages ouvert : le fermer (vers là d'où il vient).
Menu pause ouvert : reprendre().
Hint(s)
Indice 1 Quel écran ?
isinstance(self.ecran, EcranReglages).
Prototype(s)
class Cubogolf(Entity):
...
def input(self, touche):
"""Échap : ouvre la pause, ferme les Réglages, ou reprend la partie."""
pass
client/main.py — la partie s'arrête proprement
client/main.py
Jeu.mettre_en_pause
Notez la pause, désactivez la visée, cachez la flèche, annulez une charge en cours (visee.charge = None). Dans update : hors ligne, plus de physique pendant la pause (en ligne, la partie continue : le serveur n'attend personne).
Prototype(s)
class Jeu(Entity):
...
def mettre_en_pause(self, pause):
"""Hors ligne, tout s'arrête. En ligne, la partie continue mais on ne vise plus."""
pass
class Jeu(Entity):
...
def update(self):
"""À chaque image : lire les messages du réseau, faire glisser et rouler les balles des autres,
faire avancer la physique (sauf en pause hors ligne), afficher la jauge."""
pass
client/main.py
Jeu.quitter
Fermez la connexion s'il y en a une, puis détruisez tout ce que la partie a créé : décor, HUD, balle, flèche de visée, visée, balles des autres. Enfin, la partie elle-même (destroy(self)).
Danger(s)
Oublier un seul objet (la flèche, souvent : elle n'est pas enfant de la visée) le laisse flotter dans la partie suivante.
Prototype(s)
class Jeu(Entity):
...
def quitter(self):
"""Détruit tout ce que la partie a créé et ferme la connexion."""
pass
client/main.py
Jeu.changer_rendu
Le réglage « Rendu » a changé en partie : si c'est vraiment un changement et qu'un trou est chargé, détruire le décor et le reconstruire dans l'autre rendu.
Prototype(s)
class Jeu(Entity):
...
def changer_rendu(self, cubes):
"""Réglage « Rendu » changé en cours de partie : on reconstruit le décor."""
pass
client/main.py
Jeu.input : rien pendant la pause
Au début de Jeu.input : en pause, return.
Prototype(s)
class Jeu(Entity):
...
def input(self, touche):
"""Touches de la partie : Tab (scores), Entrée (prêt), N / P (visite), R (entraînement)."""
pass
Vérification
python jouer.py : chaque bouton fait ce qu'il dit ; les réglages survivent à un redémarrage ; en entraînement, Échap → Réglages → rendu « cubes » change le décor aussitôt ; Retour au menu puis nouvelle partie : rien ne reste de l'ancienne ; en solo, Retour au menu arrête le serveur local (gestionnaire des tâches).
Valider le chapitre
Toutes les cases de la dernière vérification.
Pour aller plus loin (facultatif)
Un écran « Comment jouer ». Un réglage de volume si vous avez des sons.
Questions de soutenance
Pourquoi le menu passe-t-il par le contrôleur au lieu de lancer la partie lui-même ?
Expliquez le piège des lambda dans une boucle, et pourquoi ligne() l'évite.
Une partie vivante, c'est une balle à soi qui roule vraiment, un chrono qui s'emballe quand le premier rentre sa balle, et un « Trou en un ! » qui s'affiche en grand. La logique existe déjà ; il faut la rendre visible.
Objectif
Sa propre balle (choisie dans les Réglages, vue par les autres, qui roule), le chrono en haut à droite (rouge sous 10 s), les scores spéciaux à chaque trou, le tableau avec les pars, le classement final avec les bilans.
Cours · Texture d'une sphère
Une texture de balle est une image 2 fois plus large que haute (64 × 32) enroulée autour de la sphère, comme une carte du monde sur
un globe : la largeur fait le tour de l'équateur, le haut de l'image est le pôle Nord.
Cours · Rouler sans glisser
Une balle qui roule de d sans glisser tourne de d / rayon radians, autour d'un axe horizontal et
perpendiculaire au déplacement. Pour un déplacement (dx, dz), l'axe est (dz, 0, -dx) divisé par sa longueur.
Cours · Aligner du texte
f"{'Bob':<12}" -> 'Bob ' (à gauche, sur 12 caractères)
f"{7:>4}" -> ' 7' (à droite, sur 4)
f"{5:02d}" -> '05' (2 chiffres, avec des zéros)
Le panneau des scores utilise une police à chasse fixe (tous les caractères ont la même largeur) : les colonnes s'alignent.
Les balles (personne 4)
assets/balles/
Dessiner des balles
Dessinez 2 ou 3 balles dans Piskel (64 × 32), déposez-les dans assets/balles/. Lisez tools/make_balles.py : chaque balle d'exemple est une fonction (x, y) → couleur ; écrivez-en une nouvelle (des pois ? une planète ?).
Vérification
python -m pytest tests/test_balles.py -k format, puis Réglages → Ma balle, avec l'aperçu qui tourne.
shared/balles.py
roulement
Renvoyez (axe, angle_deg) pour une balle qui roule de (dx, dz) sans glisser (voir le cours). Pas de déplacement (distance < 1e-9) : ((0.0, 1.0, 0.0), 0.0).
Étapes
distance = √(dx² + dz²).
Axe : (dz / distance, 0.0, -dx / distance).
Angle : distance / rayon radians, converti en degrés.
Prototype(s)
def roulement(dx, dz, rayon=C.BALLE_RAYON):
"""Rotation d'une balle qui roule de (dx, dz) sans glisser.
Renvoie (axe, angle_deg). L'axe est horizontal et perpendiculaire au
déplacement : (dz, 0, -dx) normalisé. L'angle = distance / rayon (en radians).
Pas de déplacement -> ((0, 1, 0), 0).
"""
pass
Code example(s)
import math
from shared.balles import roulement
from shared import constants as C
for dx, dz in [(0.12, 0), (0, -0.24), (0.3, 0.4)]:
axe, angle = roulement(dx, dz)
print(tuple(round(v, 3) + 0.0 for v in axe), round(angle, 3))
print(round(roulement(C.BALLE_RAYON * math.pi, 0)[1], 3))
print(roulement(0, 0))
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/hud.py — les textes (personne 2)
client/hud.py
texte_temps
Écrivez secondes en minutes:secondes ("2:05"), arrondi au-dessus (0,2 s affiche 0:01 : on n'affiche jamais 0:00 alors qu'il reste du temps), jamais négatif.
from client.hud import texte_temps
print(texte_temps(125), texte_temps(0.2), texte_temps(-3), texte_temps(60), texte_temps(9.5))
Output
2:05 0:01 0:00 1:00 0:10
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/hud.py
ligne_tableau
Une ligne du tableau des scores : le nom sur 12 caractères à gauche (coupé s'il est plus long), une colonne de 4 caractères par valeur (à droite), des colonnes vides (4 espaces) pour les trous pas encore joués jusqu'à nb_trous, puis le total sur 7.
Prototype(s)
def ligne_tableau(nom, valeurs, nb_trous):
"""Une ligne du tableau : le nom sur 12 caractères, une colonne de 4 par trou (vide si pas joué), le total sur 7."""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/hud.py
tableau_scores
Le tableau entier : un en-tête, une ligne « Par » si pars est donné, puis une ligne par joueur, du meilleur au pire.
Étapes
Rien : "".
joueurs = classement([dict(s) for s in scores.values()]) (des copies : classement modifie les dictionnaires).
nb_trous = le plus grand nombre de coups notés.
En-tête : "Joueur " + les numéros de trou sur 4 + " Total".
Puis ligne_tableau("Par", pars[:nb_trous], nb_trous) si pars, et une ligne_tableau par joueur.
Les lignes séparées par "\n".
Prototype(s)
def tableau_scores(scores, pars=None):
"""Tableau texte : une ligne par joueur (du meilleur au pire), une colonne par trou, le total.
scores = {id: {"nom", "coups": [...]}} ; pars = [3, 2, ...] ajoute une ligne « Par ».
"""
pass
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
client/hud.py
Hud.temps (complète)
Le chrono en haut à droite : texte_temps(secondes), en rouge sous 10 s, en blanc sinon. None : le vider.
Prototype(s)
class Hud(Entity):
...
def temps(self, secondes):
"""Chrono en haut à droite ; rouge sous 10 s. None pour le cacher."""
pass
client/main.py — l'affichage (personne 2)
client/main.py
Les imports et rouler (fournie, à lire)
Ajoutez ces imports, puis recopiez rouler sous COULEURS_JOUEURS. Elle traduit votre axe et votre angle pour Panda3D (le moteur sous Ursina, qui a l'axe z vers le haut au lieu de y). Vérifiez sur papier : une balle qui roule vers l'Est a son point le plus haut qui part vers l'Est.
Code fourni
Code fourni : à recopier tel quel
# Dans le début de client/main.py, ajoutez (et complétez l'import du HUD) :
from panda3d.core import Quat, Vec3 # noqa: E402
from client.hud import Hud, tableau_scores, texte_fin, texte_salon # noqa: E402
from shared.balles import roulement # noqa: E402
def rouler(entite, ancienne, nouvelle):
"""Fait tourner la sphère comme une balle qui roule de ancienne à nouvelle position.
roulement() donne l'axe dans le repère du jeu (y en haut) ; Panda3D, sous Ursina,
a z en haut : on échange y et z, ce qui inverse aussi le sens de rotation.
"""
(ax, ay, az), angle = roulement(nouvelle[0] - ancienne[0], nouvelle[2] - ancienne[2])
if angle:
q = Quat()
q.setFromAxisAngle(-angle, Vec3(ax, az, ay))
entite.setQuat(entite.getQuat() * q) # tourner EN PLUS de l'orientation actuelle
client/main.py
Faire rouler les sphères
Appelez rouler(entite, ancienne, nouvelle) :
Étapes
dans Jeu.dessiner_balle, entre l'ancienne et la nouvelle position dessinée, quand la balle est au sol ;
dans Jeu.maj_reseau, pour chaque balle des autres, avant et après le lissage.
Prototype(s)
class Jeu(Entity):
...
def dessiner_balle(self):
"""Place la sphère entre le pas précédent et le pas actuel (interpolation), et la fait rouler au sol."""
pass
class Jeu(Entity):
...
def maj_reseau(self):
"""Traite les messages reçus, puis fait glisser (et rouler) les balles des autres vers leur position reçue."""
pass
client/main.py
Jeu.sur_balls (complète) : le chrono
Après maj_autres : self.hud.temps(msg["temps"]). Puis, si msg.get("premier") vient d'apparaître (différent de self.premier) : le noter, et si on n'a pas fini, annoncer pendant 3 s « Zoé a fini ! Plus que 30 s ».
Prototype(s)
class Jeu(Entity):
...
def sur_balls(self, msg):
"""Message balls (20 fois par seconde) : les balles des autres, le chrono, et l'annonce
quand un premier joueur vient de finir."""
pass
client/main.py
Jeu.recevoir : scores et fin
Complétez recevoir :
Étapes
scores : garder msg["scores"] et msg["pars"] (pour la touche Tab), cacher le chrono, montrer le panneau « Scores » et le tableau.
end : le panneau « Classement final », texte_fin, et « Entrée : rejouer Échap : menu ».
Prototype(s)
class Jeu(Entity):
...
def recevoir(self, msg):
"""Réagit à un message du serveur : welcome, lobby, start_hole, balls (balles, chrono,
premier), scores, end, deconnecte."""
pass
client/main.py
Jeu.input : Tab
Tab maintenu ("tab") montre le tableau des derniers scores, avec les pars, s'il y en a ; Tab relâché ("tab up") le cache, si on a une balle.
Prototype(s)
class Jeu(Entity):
...
def input(self, touche):
"""Touches de la partie : Tab (scores), Entrée (prêt), N / P (visite), R (entraînement)."""
pass
Vérification
python jouer.py --solo avec 2 bots forts : quand un bot finit, le chrono tombe à 0:30 et l'annonce s'affiche ; Tab montre la ligne des pars ; en fin de partie, le classement au nombre de coups avec les scores spéciaux ; les bots ont des balles différentes qui roulent.
Valider le chapitre
python tools/progression.py 13.
Pour aller plus loin (facultatif)
Un son par score spécial. Des « titres » de fin : le plus de birdies, le moins de coups dans l'eau.
Questions de soutenance
Expliquez l'angle de roulement d / rayon.
Pourquoi le chrono est-il tenu par le serveur et pas par chaque client ?
Chapitre 14
Les 9 trous et l'équilibrage
QuiPersonnes 4 et 1Semaines3-11, en fondDifficulté★☆☆VersionV5
assets/trous/shared/constants.py
Lore
Le parc avait neuf trous. Il en reste quatre plans. Les cinq autres, c'est vous qui les dessinez : un virage, un pont de glace, une colline, un labyrinthe... Et chacun doit être juste : ni impossible, ni ennuyeux.
Objectif
9 trous variés, de difficulté croissante, tous finissables par le bot fort ; des réglages qui rendent le jeu agréable. Le dessin commence dès la semaine 3 (aucun code), l'équilibrage quand physique et bot sont prêts.
Cours · Concevoir un trou
Petit d'abord : 9 × 13 cases, jamais plus de 30 × 30.
Un trou = une idée : un virage, un pont de glace, un tremplin, une colline...
Des points de passage (rose) aux virages : sans eux, le bot ne trouve pas son chemin quand le trou n'est pas visible.
Le par : les coups d'un bon joueur. Le bot fort devrait faire environ le par.
Départ et trou toujours sur de l'herbe basse. Une rampe monte de l'herbe vers l'herbe haute ; si la case après le haut est plus basse, c'est un
tremplin.
assets/trous/
Trou 5 : un virage avec un point de passage
Un trou en L : le trou n'est pas visible du départ. Un point de passage rose dans le virage.
Vérification
python tools/course_preview.py 5, python jouer.py --visite 5, et le bot fort le finit.
assets/trous/
Trou 6 : du sable et de la glace
Un pont de glace au-dessus de l'eau, ou un bunker de sable devant le trou. Le joueur doit doser.
assets/trous/
Trou 7 : une colline
Des rampes qui montent vers une plateforme d'herbe haute, et redescendent.
assets/trous/
Trou 8 : un tremplin
Une rampe qui finit au-dessus du vide ou de l'eau : il faut assez d'élan, mais pas trop.
assets/trous/
Trou 9 : le final
Tout ce que vous avez : le plus difficile, le plus beau. Par 5 au moins.
Warning(s)
tools/make_trous.py permet d'écrire un trou en texte, mais réécrittrou1.png à trou4.png et trous.json : sauvegardez avant.
tools/
Tous les trous jouables
python tools/check_course.py : chaque trou se charge et le bot fort le finit. Un trou que le bot fort ne finit jamais est probablement mal dessiné (un point de passage manque ?).
journal.md
Mesurer chaque trou
Pour chaque trou, notez dans le journal : le par, la moyenne du bot fort et du bot facile (python tools/bot_bench.py --parties 20), et l'avis de 2 testeurs humains.
shared/constants.py
Équilibrer
Réglez une seule valeur à la fois dans constants.py (VITESSE_TIR_MAX, FROTTEMENT, TROU_RAYON, TROU_VITESSE_MAX, REBOND_MUR, NIVEAUX_BOT, le chrono), et relancez python -m pytest : certains tests de physique dépendent des valeurs.
Information
Exemple réel sur le trou 3 « Le tremplin » : à 80 % de puissance la balle saute et s'arrête de l'autre côté ; à 90 %, elle saute, tape le mur du fond, revient et retombe dans le vide (+1 coup). Est-ce le comportement que vous voulez ?
Valider le chapitre
Jalon V5 : 9 trous, check_course tout OK, python -m pytest tout vert.
Pour aller plus loin (facultatif)
Un trou secret débloqué en finissant les 9 sous le par. Un deuxième parcours.
Questions de soutenance
Comment avez-vous choisi le par de chaque trou ?
Comment le bot vous a-t-il aidé à vérifier vos trous ?
Chapitre 15
Finitions, .exe, soutenance
QuitousSemaines11-14Difficulté★★☆VersionV5
client/world_builder.pyclient/main.py
Lore
Le parc rouvre ses portes. Il reste à polir les cubes, à chasser les derniers bugs, à emballer le jeu pour qu'il tourne sur n'importe quel ordinateur, et à raconter ce que vous avez construit.
Objectif
Un jeu qu'on peut donner : joli, sans plantage, un .exe qui se lance sur un PC sans Python, et une soutenance préparée.
Comprendre et polir
client/world_builder.py
Comprendre le rendu texturé (personne 2)
Lisez construire_maillage, ajouter_dessus, ajouter_cote et expliquez à un collègue pourquoi une face entre deux cases de même hauteur n'est pas dessinée.
Code example(s)
from client.world_builder import construire_maillage
for carte in (["."], [".."], [".h"]):
m = construire_maillage(parcours_ascii(carte))
print(carte, m.nb_quads(), "faces")
Output
['.'] 13 faces
['..'] 20 faces
['.h'] 24 faces
La moulinette exécute cet exemple et compare votre sortie à celle-ci, caractère par caractère.
assets/atlas.png
Redessiner une tuile (personne 2)
Ouvrez assets/atlas.png (toutes les textures dans une image) et redessinez une tuile. Comparez les images par seconde entre --cubes et le rendu texturé sur un grand trou. Voir docs/04_rendu.md.
tout le projet
Chasse aux bugs (tous)
Chacun essaie de casser la partie d'un autre :
Étapes
une IP qui n'existe pas ;
le serveur fermé en pleine partie ;
un trou mal dessiné ;
reglages.json effacé, puis rempli de n'importe quoi ;
4 joueurs + 3 bots sur le réseau de l'école ;
10 minutes sur le menu sans rien faire.
Information
Chaque bug : une ligne au journal, une correction, et si possible un test qui l'aurait trouvé.
Livrer
—
Le .exe (personne 4)
pip install pyinstaller puis pyinstaller --name Cubogolf --windowed --add-data "assets;assets" jouer.py. Le jeu est dans dist/Cubogolf/ ; constants.py gère déjà les chemins dans un .exe (sys._MEIPASS).
Vérification
Entraînement et visite marchent depuis le .exe.
client/main.py
Le serveur dans le .exe (personne 4)
Un vrai problème reste : lancer_serveur démarre server/server.py avec sys.executable, qui, dans un .exe, est le jeu lui-même. Faites marcher le solo depuis le .exe.
Hint(s)
Indice 1 L'idée
Le .exe peut se relancer lui-même avec une option --serveur qui fait tourner le serveur au lieu du jeu.
Indice 2 Les étapes
Dans main(), une option --serveur (avec hôte, port, bots, niveau) qui lance le serveur au lieu de la fenêtre. Dans lancer_serveur, si getattr(sys, "frozen", False), lancer [sys.executable, "--serveur", ...]. Attention : server.py lit ses options dans sys.argv.
Vérification
Le .exe se lance sur un PC qui n'a jamais eu Python, en solo et en réseau.
—
La démonstration (tous)
Une démonstration de 5 minutes, répétée : menu, solo contre un bot, partie à 2 PC, un tremplin, un trou en un (préparé !).
—
La soutenance (tous)
Chacun présente sa partie et répond aux questions de soutenance des chapitres. Les limites assumées, dites avant qu'on les demande : le serveur fait confiance aux clients, les balles se traversent, la physique est simplifiée. Ce que vous referiez autrement.
Valider le chapitre
python -m pytest tout vert, python tools/progression.py tout coché, une partie à 4 PC sans plantage, un journal qui raconte le semestre.
Questions de soutenance
Quelle est la partie du code dont vous êtes le plus fier ? Expliquez-la.
Si c'était à refaire, que changeriez-vous dans l'architecture ?
Suivi
L'équipe
Qui a fait quoi, qui est bloqué, et le journal de bord. Tout est partagé en direct entre les membres.
Moi
Chargement…
Moulinette
À chaque push sur la branche principale, la CI GitLab envoie son rapport.json ici, tout seul.
Les exercices dont tous les tests passent sont validés pour toute l'équipe ; ceux sans test se cochent à la main.
On peut aussi importer un rapport produit en local par python tools/progression.py --rapport.