TP : Monstre sauvage

Comme promis, on va reprendre notre jeu pour le transformer en jeu solo. Maintenant on sélectionnera un monstre et l’ordinateur en choisira un autre. À chaque tour, il sélectionnera aussi quelle attaque il souhaite nous infliger.

L'aléatoire à la rescousse !

On va donc intégrer quelques doses d’aléatoire dans notre jeu, à différents niveaux :

  • Pour le choix de monstre, l’ordinateur réalisera un choix aléatoire, de même pour son nombre de PV.
  • Pour connaître l’ordre d’attaque entre les deux monstres, on pourra faire un tirage aléatoire (savoir qui commence).
  • À chaque tour, l’ordinateur sélectionnera une attaque aléatoire pour son monstre. Ce choix pourra être pondéré selon les dégâts infligés par chaque attaque.

Pour plus de généricité, on aimerait ne pas avoir à gérer l’ordinateur comme un cas spécifique, et donc ne pas faire de différence de traitement entre nos deux joueurs.

Pour cela, je vous propose de modifier la structure des joueurs (le dictionnaire tel que renvoyé par la fonction get_player) pour y ajouter une fonction (un callback) associée à la clé 'chose_attack_func', qui pourra être appelée depuis la boucle de jeu pour demander au joueur de sélectionner une attaque.
Dans le cas d’un joueur humain, cette fonction fera appel à input, et dans le cas de l’ordinateur elle opérera une sélection aléatoire. Mais la boucle de jeu n’en saura rien, ce sera totalement abstrait pour elle.

attack = player['chose_attack_func'](player)
apply_attack(player, opponent)

En bonus, on pourrait ajouter un choix pour permettre au 2ème joueur d’être un humain ou un robot, voire que les deux joueurs soient des ordinateurs pour les observer combattre. :D

Solution

Je vous propose la solution suivante, n’hésitez pas à regarder plus en détails le mécanisme de callback. J’ai aussi utilisé une interface commune entre les modules players et ia, avec une fonction get_player prenant un identifiant en argument et renvoyant un dictionnaire décrivant le joueur.

Pour la sélection du nombre de PV par l’ordinateur, j’ai utilisé une distribution normale, mais tout autre tirage serait correct.

Enfin, pour alléger le code, j’ai supprimé ce qui était relatif à la sauvegarde du jeu car ça n’est plus utile ici.

tp/__init__.py
from . import game


if __name__ == '__main__':
    game.main()
tp/__main__.py
monsters = {
    'pythachu': {
        'name': 'Pythachu',
        'attacks': ['tonnerre', 'charge'],
    },
    'pythard': {
        'name': 'Pythard',
        'attacks': ['jet-de-flotte', 'charge'],
    },
    'ponytha': {
        'name': 'Ponytha',
        'attacks': ['brûlure', 'charge'],
    },
}

attacks = {
    'charge': {'damages': 20},
    'tonnerre': {'damages': 50},
    'jet-de-flotte': {'damages': 40},
    'brûlure': {'damages': 40},
}
tp/definitions.py
import random

from .definitions import attacks
from .prompt import get_choice_input
from .players import get_player as get_real_player
from .ia import get_player as get_ia_player


def apply_attack(attack, opponent):
    opponent['pv'] -= attack['damages']
    if opponent['pv'] < 0:
        opponent['pv'] = 0


def game_turn(player, opponent):
    # Si le joueur est KO, il n'attaque pas
    if player['pv'] <= 0:
        return

    attack = player['chose_attack_func'](player)
    apply_attack(attack, opponent)

    print(
        player['monster']['name'],
        'attaque',
        opponent['monster']['name'],
        'qui perd',
        attack['damages'],
        'PV, il lui en reste',
        opponent['pv'],
    )


def get_winner(player1, player2):
    if player1['pv'] > player2['pv']:
        return player1
    else:
        return player2


def main():
    players = [get_real_player(1), get_ia_player(2)]
    random.shuffle(players)
    player1, player2 = players

    print()
    print(player1['monster']['name'], 'affronte', player2['monster']['name'])
    print()

    while player1['pv'] > 0 and player2['pv'] > 0:
        game_turn(player1, player2)
        game_turn(player2, player1)

    winner = get_winner(player1, player2)
    print('Le joueur', winner['id'], 'remporte le combat avec', winner['monster']['name'])
tp/game.py
import random

from .definitions import attacks, monsters


def chose_monster():
    values = list(monsters.values())
    monster = random.choice(values)
    return monster


def chose_attack(player):
    monster = player['monster']
    weights = [attacks[name]['damages'] for name in monster['attacks']]
    att_name = random.choices(monster['attacks'], weights=weights)[0]
    print(f"Le joueur {player['id']} utilise {att_name}")
    return attacks[att_name]


def get_player(player_id):
    monster = chose_monster()
    pv = int(random.normalvariate(100, 10))
    print(f"Le joueur {player_id} choisit {monster['name']} ({pv} PV)")

    return {
        'id': player_id,
        'monster': monster,
        'pv': pv,
        'chose_attack_func': chose_attack,
    }
tp/ia.py
from .definitions import attacks, monsters
from .prompt import get_choice_input


def chose_attack(player):
    print('Joueur', player['id'], 'quelle attaque utilisez-vous ?')
    for name in player['monster']['attacks']:
        print('-', name.capitalize(), -attacks[name]['damages'], 'PV')

    return get_choice_input(attacks, 'Attaque invalide')


def get_player(player_id):
    print('Monstres disponibles :')
    for monster in monsters.values():
        print('-', monster['name'])

    print('Joueur', player_id, 'quel monstre choisissez-vous ?')
    monster = get_choice_input(monsters, 'Monstre invalide')
    pv = int(input('Quel est son nombre de PV ? '))
    return {
        'id': player_id,
        'monster': monster,
        'pv': pv,
        'chose_attack_func': chose_attack,
    }
tp/players.py
def get_choice_input(choices, error_message):
    entry = input('> ').lower()
    while entry not in choices:
        print(error_message)
        entry = input('> ').lower()
    return choices[entry]
tp/prompt.py

Animations

Un tout petit exercice avant de finir.

L’ordinateur est un peu trop rapide à jouer, on a à peine le temps de voir ce qui se passe. On serait alors tenté d’ajouter un simple time.sleep(1) pour ralentir l’exécution, mais on se demanderait alors ce qui se passe.

Une autre idée serait d’ajouter des animations pendant les choix de l’ordinateur, afin de voir qu’il se passe quelque chose sans que ça ne se passe trop vite.

Et pour cela, on va simplement utiliser les fonctions print et time.sleep.

Par exemple comment représenter une barre de progression en animation ? On peut afficher un caractère, attendre, afficher un autre caractère, etc.
Pour cela, on va appeler print avec l’argument end='' (pour ne pas afficher de retour à la ligne) dans une boucle. En sortie de boucle, on s’occupera de revenir à la ligne pour finaliser la barre.

import time

for _ in range(10):
    print('-', end='')
    time.sleep(0.1)

print()

Si vous exécutez ce code, vous verrez probablement la barre complète s’afficher d’un seul coup au bout d’une seconde, sans aucune animation.

Cela est dû au mécanisme de flush (mémoire tampon) dont je vous avais parlé : en l’absence de retour à la ligne, print a simplement placé le texte en mémoire tampon mais n’a rien écrit réellement sur le terminal. On corrige ça an ajoutant l’argument flush=True à l’appel.

import time

for _ in range(10):
    print('-', end='', flush=True)
    time.sleep(0.1)

print()

Pour aller plus loin, on peut aussi utiliser le caractère spécial \b qui permet de revenir en arrière sur la ligne et donc d’effacer le dernier caractère imprimé.

Solution

Rien de bien méchant, je présente ici le fichier tp/ia.py uniquement qui est le seul à changer.

import random
import time

from .definitions import attacks, monsters


def wait(steps, step_duration=0.1):
    print('[', end='', flush=True)
    for _ in range(steps):
        print('>', end='', flush=True)
        time.sleep(step_duration)
        print('\b#', end='', flush=True)
    print(']')


def chose_monster():
    values = list(monsters.values())
    monster = random.choice(values)
    wait(10)
    return monster


def chose_attack(player):
    monster = player['monster']
    weights = [attacks[name]['damages'] for name in monster['attacks']]
    att_name = random.choices(monster['attacks'], weights=weights)[0]

    wait(10)
    print(f"Le joueur {player['id']} utilise {att_name}")
    return attacks[att_name]


def get_player(player_id):
    monster = chose_monster()
    pv = int(random.normalvariate(100, 10))
    print(f"Le joueur {player_id} choisit {monster['name']} ({pv} PV)")

    return {
        'id': player_id,
        'monster': monster,
        'pv': pv,
        'chose_attack_func': chose_attack,
    }
tp/ia.py

Si les animations dans le terminal vous intéressent et que vous souhaitez aller plus loin, je vous conseille de regarder du côté du module curses de Python, qui permet de dessiner dans le terminal plus simplement qu’avec print et '\b'.

La bibliothèque tierce prompt_toolkit peut aussi être un bon point d’entrée.