Part 12 - Augmenter la difficulté


Notre jeu ne ressemble plus vraiment à un Roguelike : il est bien trop facile ! Qui plus est la difficulté n’augmente quand l’on progresse. Nous allons y remédier dans ce chapitre en rendant les ennemis plus fort et en répartissant les ennemis et les objets à travers les niveaux de profondeur.

Avant de s’attaquer à ça, nous allons résoudre un problème de design qui nous causera des soucis plus tard : notre fonction de choix aléatoire pour les monstres et les objets. Pour l’instant on tire un entier entre 1 et 100 et on vérifie si ce nombre est inférieur à une certaine “chance” pour un certain objet. Cela convient pour un nombre limité d’options mais vous voudrez certainement ajouter plus de deux ennemis et plus de quatre objets.

Il est plus judicieux de tirer au sort en attribuant à chaque objet un “poids” qui indique sa probabilité d’apparaître. Nous pourrions les options et leurs poids à une fonction qui en choisirait un aléatoirement et renverrait la réponse.

Créez un fichier appelé random_utils.py et ajoutez-y :

from random import randint


def random_choice_index(chances):
    random_chance = randint(1, sum(chances))

    running_sum = 0
    choice = 0
    for w in chances:
        running_sum += w

        if random_chance <= running_sum:
            return choice
        choice += 1


def random_choice_from_dict(choice_dict):
    choices = list(choice_dict.keys())
    chances = list(choice_dict.values())

    return choices[random_choice_index(chances)]

Mettons cette nouvelle fonction en action. Ouvrez game_map.py et modifiez ainsi :

...
from map_objects.tile import Tile

+from random_utils import random_choice_from_dict

from render_functions import RenderOrder
...
...
from map_objects.tile import Tile

from random_utils import random_choice_from_dict

from render_functions import RenderOrder
...
    def place_entities(self, room, entities, max_monsters_per_room, max_items_per_room):
        number_of_monsters = randint(0, max_monsters_per_room)
        number_of_items = randint(0, max_items_per_room)

+       monster_chances = {'orc': 80, 'troll': 20}
+       item_chances = {'healing_potion': 70, 'lightning_scroll': 10, 'fireball_scroll': 10, 'confusion_scroll': 10}

        for i in range(number_of_monsters):
            x = randint(room.x1 + 1, room.x2 - 1)
            y = randint(room.y1 + 1, room.y2 - 1)

            if not any([entity for entity in entities if entity.x == x and entity.y == y]):
+               monster_choice = random_choice_from_dict(monster_chances)

-               if randint(0, 100) < 80:
+               if monster_choice == 'orc':
                    fighter_component = Fighter(hp=10, defense=0, power=3, xp=35)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
                                     render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
                else:
                    fighter_component = Fighter(hp=16, defense=1, power=4, xp=100)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
                                     render_order=RenderOrder.ACTOR, ai=ai_component)

                entities.append(monster)

        for i in range(number_of_items):
            x = randint(room.x1 + 1, room.x2 - 1)
            y = randint(room.y1 + 1, room.y2 - 1)

            if not any([entity for entity in entities if entity.x == x and entity.y == y]):
-               item_chance = randint(0, 100)
+               item_choice = random_choice_from_dict(item_chances)

-               if item_chance < 70:
+               if item_choice == 'healing_potion':
                    item_component = Item(use_function=heal, amount=4)
                    item = Entity(x, y, '!', libtcod.violet, 'Healing Potion', render_order=RenderOrder.ITEM,
                                  item=item_component)
-               elif item_chance < 80:
+               elif item_choice == 'fireball_scroll':
                    item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
                        'Left-click a target tile for the fireball, or right-click to cancel.', libtcod.light_cyan),
                                          damage=12, radius=3)
                    item = Entity(x, y, '#', libtcod.red, 'Fireball Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
-               elif item_chance < 90:
+               elif item_choice == 'confusion_scroll':
                    item_component = Item(use_function=cast_confuse, targeting=True, targeting_message=Message(
                        'Left-click an enemy to confuse it, or right-click to cancel.', libtcod.light_cyan))
                    item = Entity(x, y, '#', libtcod.light_pink, 'Confusion Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                else:
                    item_component = Item(use_function=cast_lightning, damage=20, maximum_range=5)
                    item = Entity(x, y, '#', libtcod.yellow, 'Lightning Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)

                entities.append(item)
    def place_entities(self, room, entities, max_monsters_per_room, max_items_per_room):
        number_of_monsters = randint(0, max_monsters_per_room)
        number_of_items = randint(0, max_items_per_room)

        monster_chances = {'orc': 80, 'troll': 20}
        item_chances = {'healing_potion': 70, 'lightning_scroll': 10, 'fireball_scroll': 10, 'confusion_scroll': 10}

        for i in range(number_of_monsters):
            x = randint(room.x1 + 1, room.x2 - 1)
            y = randint(room.y1 + 1, room.y2 - 1)

            if not any([entity for entity in entities if entity.x == x and entity.y == y]):
                monster_choice = random_choice_from_dict(monster_chances)

                if randint(0, 100) < 80:
                if monster_choice == 'orc':
                    fighter_component = Fighter(hp=10, defense=0, power=3, xp=35)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
                                     render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
                else:
                    fighter_component = Fighter(hp=16, defense=1, power=4, xp=100)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
                                     render_order=RenderOrder.ACTOR, ai=ai_component)

                entities.append(monster)

        for i in range(number_of_items):
            x = randint(room.x1 + 1, room.x2 - 1)
            y = randint(room.y1 + 1, room.y2 - 1)

            if not any([entity for entity in entities if entity.x == x and entity.y == y]):
                item_chance = randint(0, 100)
                item_choice = random_choice_from_dict(item_chances)

                if item_chance < 70:
                if item_choice == 'healing_potion':
                    item_component = Item(use_function=heal, amount=4)
                    item = Entity(x, y, '!', libtcod.violet, 'Healing Potion', render_order=RenderOrder.ITEM,
                                  item=item_component)
                elif item_chance < 80:
                elif item_choice == 'fireball_scroll':
                    item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
                        'Left-click a target tile for the fireball, or right-click to cancel.', libtcod.light_cyan),
                                          damage=12, radius=3)
                    item = Entity(x, y, '#', libtcod.red, 'Fireball Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                elif item_chance < 90:
                elif item_choice == 'confusion_scroll':
                    item_component = Item(use_function=cast_confuse, targeting=True, targeting_message=Message(
                        'Left-click an enemy to confuse it, or right-click to cancel.', libtcod.light_cyan))
                    item = Entity(x, y, '#', libtcod.light_pink, 'Confusion Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                else:
                    item_component = Item(use_function=cast_lightning, damage=20, maximum_range=5)
                    item = Entity(x, y, '#', libtcod.yellow, 'Lightning Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)

                entities.append(item)

On attribue à chaque ennemi ou objet un poids et la sélection est effectuée par notre nouvelle fonction. Remarquez que le cumul des poids est 100 mais cela n’est pas indispensable.

C’est beaucoup plus extensible que notre solution précédente mais nous n’avons pas encore atteint notre objectif. Nous aimerions avoir des poids dépendant du niveau de profondeur. Ainsi, plus on descend et plus on a de chances de rencontrer des Trolls plutôt que des Orcs et plus on a de chance de trouver des objets utiles plutôt que seulement des potions de soin.

Créons une nouvelle fonction qui prendra un intervalle de valeurs indiquant quand une chose apparaît dans le donjon et avec quel poids. La fonction renvoie le poids correspondant au niveau du donjon. Elle va dans random_utils.py et ressemble à ça :

from random import randint


+def from_dungeon_level(table, dungeon_level):
+   for (value, level) in reversed(table):
+       if dungeon_level >= level:
+           return value
+
+   return 0


def random_choice_index(chances):
    ...
from random import randint


def from_dungeon_level(table, dungeon_level):
    for (value, level) in reversed(table):
        if dungeon_level >= level:
            return value

    return 0


def random_choice_index(chances):
    ...

Cela n’a peut-être pas beaucoup de sens maintenant mais mettons la en action. On peut retirer les variables max_monsters_per_room et max_items_per_room qui sont maintenant déterminées par le niveau de profondeur et non plus passées directement à la fonction.

...
from map_objects.tile import Tile

-from random_utils import random_choice_from_dict
+from random_utils import from_dungeon_level, random_choice_from_dict

from render_functions import RenderOrder
...
from map_objects.tile import Tile

from random_utils import from_dungeon_level, random_choice_from_dict

from render_functions import RenderOrder
-   def place_entities(self, room, entities, max_monsters_per_room, max_items_per_room):
+   def place_entities(self, room, entities):
+       max_monsters_per_room = from_dungeon_level([[2, 1], [3, 4], [5, 6]], self.dungeon_level)
+       max_items_per_room = from_dungeon_level([[1, 1], [2, 4]], self.dungeon_level)

        number_of_monsters = randint(0, max_monsters_per_room)
        number_of_items = randint(0, max_items_per_room)

-       monster_chances = {'orc': 80, 'troll': 20}
-       item_chances = {'healing_potion': 70, 'lightning_scroll': 10, 'fireball_scroll': 10, 'confusion_scroll': 10}

+       monster_chances = {
+           'orc': 80,
+           'troll': from_dungeon_level([[15, 3], [30, 5], [60, 7]], self.dungeon_level)
+       }

+       item_chances = {
+           'healing_potion': 35,
+           'lightning_scroll': from_dungeon_level([[25, 4]], self.dungeon_level),
+           'fireball_scroll': from_dungeon_level([[25, 6]], self.dungeon_level),
+           'confusion_scroll': from_dungeon_level([[10, 2]], self.dungeon_level)
+       }
    def place_entities(self, room, entities, max_monsters_per_room, max_items_per_room):
    def place_entities(self, room, entities):
        max_monsters_per_room = from_dungeon_level([[2, 1], [3, 4], [5, 6]], self.dungeon_level)
        max_items_per_room = from_dungeon_level([[1, 1], [2, 4]], self.dungeon_level)

        number_of_monsters = randint(0, max_monsters_per_room)
        number_of_items = randint(0, max_items_per_room)

        monster_chances = {'orc': 80, 'troll': 20}
        item_chances = {'healing_potion': 70, 'lightning_scroll': 10, 'fireball_scroll': 10, 'confusion_scroll': 10}

        monster_chances = {
            'orc': 80,
            'troll': from_dungeon_level([[15, 3], [30, 5], [60, 7]], self.dungeon_level)
        }

        item_chances = {
            'healing_potion': 35,
            'lightning_scroll': from_dungeon_level([[25, 4]], self.dungeon_level),
            'fireball_scroll': from_dungeon_level([[25, 6]], self.dungeon_level),
            'confusion_scroll': from_dungeon_level([[10, 2]], self.dungeon_level)
        }

Nous aurons aussi besoin de mettre à jour notre appel à place_entities depuis make_map.

                        ...
                        self.create_h_tunnel(prev_x, new_x, new_y)

-               self.place_entities(new_room, entities, max_monsters_per_room, max_items_per_room)
+               self.place_entities(new_room, entities)

                rooms.append(new_room)
                ...
                        ...
                        self.create_h_tunnel(prev_x, new_x, new_y)

                self.place_entities(new_room, entities, max_monsters_per_room, max_items_per_room)
                self.place_entities(new_room, entities)

                rooms.append(new_room)
                ...

Parce que nous avons retiré max_monsters_per_room et max_items_per_room de l’appel de la fonction, nous pouvons aussi les retirer de la définition de make_map.

-   def make_map(self, max_rooms, room_min_size, room_max_size, map_width, map_height, player, entities,
-                max_monsters_per_room, max_items_per_room):
+   def make_map(self, max_rooms, room_min_size, room_max_size, map_width, map_height, player, entities):
    def make_map(self, max_rooms, room_min_size, room_max_size, map_width, map_height, player, entities,
                 max_monsters_per_room, max_items_per_room):
    def make_map(self, max_rooms, room_min_size, room_max_size, map_width, map_height, player, entities):

Nous devrions aussi retirer ces variables des appels de make_map dans initialize_new_game.py

-   game_map.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
-                     constants['map_width'], constants['map_height'], player, entities,
-                     constants['max_monsters_per_room'], constants['max_items_per_room'])
+   game_map.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
+                     constants['map_width'], constants['map_height'], player, entities)
    game_map.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
                      constants['map_width'], constants['map_height'], player, entities,
                      constants['max_monsters_per_room'], constants['max_items_per_room'])
    game_map.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
                      constants['map_width'], constants['map_height'], player, entities)

… and in next_floor (in game_map.py):

-       self.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
-                     constants['map_width'], constants['map_height'], player, entities,
-                     constants['max_monsters_per_room'], constants['max_items_per_room'])
+       self.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
+                     constants['map_width'], constants['map_height'], player, entities)
        self.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
                      constants['map_width'], constants['map_height'], player, entities,
                      constants['max_monsters_per_room'], constants['max_items_per_room'])
        self.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
                      constants['map_width'], constants['map_height'], player, entities)

Notre nouveau donjon devient plus dangereux au fur et à mesure de notre descente ! Le jeu reste très facile néanmoins aussi changeons ça en modifiant les valeurs par défaut pour chaque composant de l’entité Fighter notamment le joueur lui même. Nous allons aussi modifier les valeurs des potions de soin, du parchemin boule de feu et du parchemin d’éclair.

def get_game_variables(constants):
-   fighter_component = Fighter(hp=30, defense=2, power=5)
+   fighter_component = Fighter(hp=100, defense=1, power=4)
    inventory_component = Inventory(26)
    ...
def get_game_variables(constants):
    fighter_component = Fighter(hp=30, defense=2, power=5)
    fighter_component = Fighter(hp=100, defense=1, power=4)
    inventory_component = Inventory(26)
    ...
                ...
                if monster_choice == 'orc':
-                   fighter_component = Fighter(hp=10, defense=0, power=3, xp=35)
+                   fighter_component = Fighter(hp=20, defense=0, power=4, xp=35)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
                                     render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
                else:
-                   fighter_component = Fighter(hp=16, defense=1, power=4, xp=100)
+                   fighter_component = Fighter(hp=30, defense=2, power=8, xp=100)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
                                     render_order=RenderOrder.ACTOR, ai=ai_component)
                ...
                ...
                if monster_choice == 'orc':
                    fighter_component = Fighter(hp=10, defense=0, power=3, xp=35)
                    fighter_component = Fighter(hp=20, defense=0, power=4, xp=35)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
                                     render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
                else:
                    fighter_component = Fighter(hp=16, defense=1, power=4, xp=100)
                    fighter_component = Fighter(hp=30, defense=2, power=8, xp=100)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
                                     render_order=RenderOrder.ACTOR, ai=ai_component)
                ...
            ...
            if not any([entity for entity in entities if entity.x == x and entity.y == y]):
                item_choice = random_choice_from_dict(item_chances)

                if item_choice == 'healing_potion':
-                   item_component = Item(use_function=heal, amount=4)
+                   item_component = Item(use_function=heal, amount=40)
                    item = Entity(x, y, '!', libtcod.violet, 'Healing Potion', render_order=RenderOrder.ITEM,
                                  item=item_component)
                elif item_choice == 'fireball_scroll':
-                   item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
-                       'Left-click a target tile for the fireball, or right-click to cancel.', libtcod.light_cyan),
-                                         damage=12, radius=3)
+                   item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
+                       'Left-click a target tile for the fireball, or right-click to cancel.', libtcod.light_cyan),
+                                         damage=25, radius=3)
                    item = Entity(x, y, '#', libtcod.red, 'Fireball Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                elif item_choice == 'confusion_scroll':
                    item_component = Item(use_function=cast_confuse, targeting=True, targeting_message=Message(
                        'Left-click an enemy to confuse it, or right-click to cancel.', libtcod.light_cyan))
                    item = Entity(x, y, '#', libtcod.light_pink, 'Confusion Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                else:
-                   item_component = Item(use_function=cast_lightning, damage=20, maximum_range=5)
+                   item_component = Item(use_function=cast_lightning, damage=40, maximum_range=5)
                    item = Entity(x, y, '#', libtcod.yellow, 'Lightning Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                ...
            ...
            if not any([entity for entity in entities if entity.x == x and entity.y == y]):
                item_choice = random_choice_from_dict(item_chances)

                if item_choice == 'healing_potion':
                    item_component = Item(use_function=heal, amount=4)
                    item_component = Item(use_function=heal, amount=40)
                    item = Entity(x, y, '!', libtcod.violet, 'Healing Potion', render_order=RenderOrder.ITEM,
                                  item=item_component)
                elif item_choice == 'fireball_scroll':
                    item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
                        'Left-click a target tile for the fireball, or right-click to cancel.', libtcod.light_cyan),
                                          damage=12, radius=3)
                    item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
                        'Left-click a target tile for the fireball, or right-click to cancel.', libtcod.light_cyan),
                                          damage=25, radius=3)
                    item = Entity(x, y, '#', libtcod.red, 'Fireball Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                elif item_choice == 'confusion_scroll':
                    item_component = Item(use_function=cast_confuse, targeting=True, targeting_message=Message(
                        'Left-click an enemy to confuse it, or right-click to cancel.', libtcod.light_cyan))
                    item = Entity(x, y, '#', libtcod.light_pink, 'Confusion Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                else:
                    item_component = Item(use_function=cast_lightning, damage=20, maximum_range=5)
                    item_component = Item(use_function=cast_lightning, damage=40, maximum_range=5)
                    item = Entity(x, y, '#', libtcod.yellow, 'Lightning Scroll', render_order=RenderOrder.ITEM,
                                  item=item_component)
                ...

Et voilà pour aujourd’hui. La prochaine partie sera la dernière, nous y sommes presque.

Si vous voulez voir le code actuel entièrement, [cliquez ici](https://github.com/TStand90/roguelike_tutorial_revised/tree/part-12.

Cliquez ici pour vous rendre à la partie suivante de ce tutoriel.