Part 13 - S’équiper


Pour la dernière partie de notre tutoriel nous allons ajouter de l’équipement. L’équipement est un genre d’objet qui le joueur peut porter pour améliorer ses statistiques. De toute évidence cela peut-être plus complexe, selon le jeu, mais je vous laisse le soin de l’implémenter selon vos besoins. Pour ce tutoriel, équiper une arme améliore la puissance d’attaque et équiper un bouclier améliore la défense.

Vous l’avez peut-être deviné, nous avons besoin d’un nouveau composant qui nous indique quels objets sont équipables et quels effets ils procurent. Ce composant s’appelera Equippable et nous l’incluerons à un fichier appelé equippable.py qui ira dans le dossier components.

class Equippable:
    def __init__(self, slot, power_bonus=0, defense_bonus=0, max_hp_bonus=0):
        self.slot = slot
        self.power_bonus = power_bonus
        self.defense_bonus = defense_bonus
        self.max_hp_bonus = max_hp_bonus

power_bonus, defense_bonus et max_hp_bonus sont les gains que le joueur obtient en portant certains objets. Une arme donne un bonus d’attaque et un boucle donne un bonus de défense. Nous n’inclurons aucun objet donnant un de points de vie mais vous pouvez ajouter une armure ou une bague qui améliore la santé.

Qu’est-ce que slot ? Cela décrit l’endroit où sera porté l’objet. Le joueur aura deux emplacements différents disponibles : la main principale pour l’arme et la main secondaire pour le bouclier. Nous implémenterons ça à l’aide d’un enum. Créez un fichier appelé equipment_slots.py dans le dossier de base et ajoutez-y :

from enum import Enum


class EquipmentSlots(Enum):
    MAIN_HAND = 1
    OFF_HAND = 2

Vous pouvez étendre ça autant que vous le souhaitez en donnant au joueur des emplacements comme la tête, le corps, les jambes et les doigts.

On a maintenant ce qu’il nous faut pour rendre les objets équipables. Mais que va-t-on porter ? Pour ça il nous faut un autre composant appelé Equipment. Ajoutez la suite à un nouveau fichier du dossier components appelé equipment.py :

from equipment_slots import EquipmentSlots


class Equipment:
    def __init__(self, main_hand=None, off_hand=None):
        self.main_hand = main_hand
        self.off_hand = off_hand

    @property
    def max_hp_bonus(self):
        bonus = 0

        if self.main_hand and self.main_hand.equippable:
            bonus += self.main_hand.equippable.max_hp_bonus

        if self.off_hand and self.off_hand.equippable:
            bonus += self.off_hand.equippable.max_hp_bonus

        return bonus

    @property
    def power_bonus(self):
        bonus = 0

        if self.main_hand and self.main_hand.equippable:
            bonus += self.main_hand.equippable.power_bonus

        if self.off_hand and self.off_hand.equippable:
            bonus += self.off_hand.equippable.power_bonus

        return bonus

    @property
    def defense_bonus(self):
        bonus = 0

        if self.main_hand and self.main_hand.equippable:
            bonus += self.main_hand.equippable.defense_bonus

        if self.off_hand and self.off_hand.equippable:
            bonus += self.off_hand.equippable.defense_bonus

        return bonus

    def toggle_equip(self, equippable_entity):
        results = []

        slot = equippable_entity.equippable.slot

        if slot == EquipmentSlots.MAIN_HAND:
            if self.main_hand == equippable_entity:
                self.main_hand = None
                results.append({'dequipped': equippable_entity})
            else:
                if self.main_hand:
                    results.append({'dequipped': self.main_hand})

                self.main_hand = equippable_entity
                results.append({'equipped': equippable_entity})
        elif slot == EquipmentSlots.OFF_HAND:
            if self.off_hand == equippable_entity:
                self.off_hand = None
                results.append({'dequipped': equippable_entity})
            else:
                if self.off_hand:
                    results.append({'dequipped': self.off_hand})

                self.off_hand = equippable_entity
                results.append({'equipped': equippable_entity})

        return results

Cela fait beaucoup de code d’un coup aussi nous allons le découper un peu.

Les deux variables main_hand et off_hand vont contenir les entités qui sont équipées. Si elles pointes vers None, cela signifie que rien n’est porté dans cet emplacement.

Les trois propriétés font toutes sensiblement la même chose : elles ajoutent les “bonus” de la main principale et de la main secondaire et renvoie la valeur. Comme nous employons des propriétés on peut y accéder comme à une variable ce qui sera rapidement commode. Si le joueur porte un objet en main principale et en main secondaire qui améliore l’attaque, par exemple, on récupère le bonus de la même manière.

toggle_equip (basculer l’équipement) est ce qu’on appelle quand on met ou qu’on enlève un objet dans l’emplacement. Si l’objet n’était pas déjà porté, on l’équipe et s’il était déjà équipé on l’enlève. On renvoie le résultat de cette opération comme précédemment et c’est engine qui va se charger de la suite du procédé.

Comme pour les autres composants crées, nous aurons besoin d’ajouter ces nouveaux à la classe Entity.

import tcod as libtcod

import math

+from components.item import Item

from render_functions import RenderOrder


class Entity:
    def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None,
-                item=None, inventory=None, stairs=None, level=None):
+                item=None, inventory=None, stairs=None, level=None, equipment=None, equippable=None):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks = blocks
        self.render_order = render_order
        self.fighter = fighter
        self.ai = ai
        self.item = item
        self.inventory = inventory
        self.stairs = stairs
        self.level = level
+       self.equipment = equipment
+       self.equippable = equippable

        if self.fighter:
            self.fighter.owner = self

        if self.ai:
            self.ai.owner = self

        if self.item:
            self.item.owner = self

        if self.inventory:
            self.inventory.owner = self

        if self.stairs:
            self.stairs.owner = self

        if self.level:
            self.level.owner = self

+       if self.equipment:
+           self.equipment.owner = self
+
+       if self.equippable:
+           self.equippable.owner = self
+
+           if not self.item:
+               item = Item()
+               self.item = item
+               self.item.owner = self

    def move(self, dx, dy):
        ...
import tcod as libtcod

import math

from components.item import Item

from render_functions import RenderOrder


class Entity:
    def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None,
                 item=None, inventory=None, stairs=None, level=None, equipment=None, equippable=None):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks = blocks
        self.render_order = render_order
        self.fighter = fighter
        self.ai = ai
        self.item = item
        self.inventory = inventory
        self.stairs = stairs
        self.level = level
        self.equipment = equipment
        self.equippable = equippable

        if self.fighter:
            self.fighter.owner = self

        if self.ai:
            self.ai.owner = self

        if self.item:
            self.item.owner = self

        if self.inventory:
            self.inventory.owner = self

        if self.stairs:
            self.stairs.owner = self

        if self.level:
            self.level.owner = self

        if self.equipment:
            self.equipment.owner = self

        if self.equippable:
            self.equippable.owner = self

            if not self.item:
                item = Item()
                self.item = item
                self.item.owner = self

    def move(self, dx, dy):
        ...

Remarquez que si l’entité n’a pas de composant Item on lui en ajoute un. Cela s’explique car chaque pièce d’équipement est aussi un objet qu’on ajoute à l’inventaire, ramasse et dépose.

Ajoutons un nouveau composant Equipment au joueur dans initialize_new_game.py :

    ...
    level_component = Level()
+   equipment_component = Equipment()
-   player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
-                   fighter=fighter_component, inventory=inventory_component, level=level_component)
+   player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
+                   fighter=fighter_component, inventory=inventory_component, level=level_component,
+                   equipment=equipment_component)
    entities = [player]
    ...
    ...
    level_component = Level()
    equipment_component = Equipment()
    player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
                    fighter=fighter_component, inventory=inventory_component, level=level_component)
    player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
                    fighter=fighter_component, inventory=inventory_component, level=level_component,
                    equipment=equipment_component)
    entities = [player]
    ...

Assurez-vous d’importer le composant dans le fichier :

import tcod as libtcod

+from components.equipment import Equipment
from components.fighter import Fighter
from components.inventory import Inventory
from components.level import Level
...
import tcod as libtcod

from components.equipment import Equipment
from components.fighter import Fighter
from components.inventory import Inventory
from components.level import Level
...

Aussi, comment le joueur procède-t-il pour porter un objet ? L’équipement sera sivible depuis l’écran d’inventaire comme n’importe quel objet utilisable aussi pourquoi ne pas étendre cette fonctionnalité ? Nous pouvons modifier la méthode use de Inventory pour équiper un objet si c’est possible :

        ...
        if item_component.use_function is None:
-           results.append({'message': Message('The {0} cannot be used'.format(item_entity.name), libtcod.yellow)})
+           equippable_component = item_entity.equippable
+
+           if equippable_component:
+               results.append({'equip': item_entity})
+           else:
+               results.append({'message': Message('The {0} cannot be used'.format(item_entity.name), libtcod.yellow)})
        else:
            ...
        ...
        if item_component.use_function is None:
            results.append({'message': Message('The {0} cannot be used'.format(item_entity.name), libtcod.yellow)})
            equippable_component = item_entity.equippable

            if equippable_component:
                results.append({'equip': item_entity})
            else:
                results.append({'message': Message('The {0} cannot be used'.format(item_entity.name), libtcod.yellow)})
        else:
            ...

Maintenant la méthode vérifie si l’objet est équipable et, si c’est le cas, nous renvoyons le résultat. Sinon, nous affichons un avertissement nous indiquant que c’est impossible, comme d’habitude.

Mettons la méthode toggle_equip en action. Dans engine.py :

            ...
            item_dropped = player_turn_result.get('item_dropped')
+           equip = player_turn_result.get('equip')
            targeting = player_turn_result.get('targeting')
            ...
            ...
            item_dropped = player_turn_result.get('item_dropped')
            equip = player_turn_result.get('equip')
            targeting = player_turn_result.get('targeting')
            ...
            ...
            if item_dropped:
                entities.append(item_dropped)

                game_state = GameStates.ENEMY_TURN

+           if equip:
+               equip_results = player.equipment.toggle_equip(equip)
+
+               for equip_result in equip_results:
+                   equipped = equip_result.get('equipped')
+                   dequipped = equip_result.get('dequipped')
+
+                   if equipped:
+                       message_log.add_message(Message('You equipped the {0}'.format(equipped.name)))
+
+                   if dequipped:
+                       message_log.add_message(Message('You dequipped the {0}'.format(dequipped.name)))
+
+               game_state = GameStates.ENEMY_TURN

            if targeting:
            ...
            ...
            if item_dropped:
                entities.append(item_dropped)

                game_state = GameStates.ENEMY_TURN

            if equip:
                equip_results = player.equipment.toggle_equip(equip)

                for equip_result in equip_results:
                    equipped = equip_result.get('equipped')
                    dequipped = equip_result.get('dequipped')

                    if equipped:
                        message_log.add_message(Message('You equipped the {0}'.format(equipped.name)))

                    if dequipped:
                        message_log.add_message(Message('You dequipped the {0}'.format(dequipped.name)))

                game_state = GameStates.ENEMY_TURN

            if targeting:
            ...

Il y a un petit bug dans notre implémentation. Le joueur peut déposer un objet depuis l’inventaire mais continuer à le porter sur lui ! Ce n’est pas correct aussi nous allons résoudre ce problème dans la méthode drop_item de Inventory :

   ...
    def drop_item(self, item):
        results = []

+       if self.owner.equipment.main_hand == item or self.owner.equipment.off_hand == item:
+           self.owner.equipment.toggle_equip(item)

        item.x = self.owner.x
        ...
   ...
    def drop_item(self, item):
        results = []

        if self.owner.equipment.main_hand == item or self.owner.equipment.off_hand == item:
            self.owner.equipment.toggle_equip(item)

        item.x = self.owner.x
        ...

Mais qu’est ce qu’équiper un objet fait réellement ? Cela devrait donner un bonus au joueur mais cela ne fait rien pour l’instant. Pourquoi ? Parce que notre composant FIghter ne tient pas compte du bonus d’équipement pour l’instant. Réglons ça :

Nous devons ajuster la manière d’obtenir les valeurs de Fighter. Il serait plus judicieux que max_hp, power et defense soient des propriétés afin qu’on puisse les calculer comme une base ajouté à un bonus. Changeons la fonction d’initialisation pour régler les bases de chacune de ces valeurs et nous ajouterons les propriétés à chacun pour remplacer nos anciennes variables.

class Fighter:
    def __init__(self, hp, defense, power, xp=0):
-       self.max_hp = hp
+       self.base_max_hp = hp
        self.hp = hp
-       self.defense = defense
+       self.base_defense = defense
-       self.power = power
+       self.base_power = power
        self.xp = xp

+   @property
+   def max_hp(self):
+       if self.owner and self.owner.equipment:
+           bonus = self.owner.equipment.max_hp_bonus
+       else:
+           bonus = 0
+
+       return self.base_max_hp + bonus
+
+   @property
+   def power(self):
+       if self.owner and self.owner.equipment:
+           bonus = self.owner.equipment.power_bonus
+       else:
+           bonus = 0
+
+       return self.base_power + bonus
+
+   @property
+   def defense(self):
+       if self.owner and self.owner.equipment:
+           bonus = self.owner.equipment.defense_bonus
+       else:
+           bonus = 0
+
+       return self.base_defense + bonus

    def take_damage(self, amount):
        ...
class Fighter:
    def __init__(self, hp, defense, power, xp=0):
        self.max_hp = hp
        self.base_max_hp = hp
        self.hp = hp
        self.defense = defense
        self.base_defense = defense
        self.power = power
        self.base_power = power
        self.xp = xp

    @property
    def max_hp(self):
        if self.owner and self.owner.equipment:
            bonus = self.owner.equipment.max_hp_bonus
        else:
            bonus = 0

        return self.base_max_hp + bonus

    @property
    def power(self):
        if self.owner and self.owner.equipment:
            bonus = self.owner.equipment.power_bonus
        else:
            bonus = 0

        return self.base_power + bonus

    @property
    def defense(self):
        if self.owner and self.owner.equipment:
            bonus = self.owner.equipment.defense_bonus
        else:
            bonus = 0

        return self.base_defense + bonus

    def take_damage(self, amount):
        ...

Ainsi maintenant quand nous nous relevons la valeur de power, par exemple, nous prendrons en compte l’équipement du joueur.

Dans la plupart des cas cela suffit. La seule chose qui pose encore problème dans le code précédent est le gain d’un niveau parce qu’on nous augmentons directement les valeurs de max_hp, defense et power alors qu’on devrait augmenter leurs valeurs de base. C’est plutôt simple à résoudre, ouvrez engine.py et apportez les modifications suivantes :

        ...
        if level_up:
            if level_up == 'hp':
-               player.fighter.max_hp += 20
+               player.fighter.base_max_hp += 20
                player.fighter.hp += 20
            elif level_up == 'str':
-               player.fighter.power += 1
+               player.fighter.base_power += 1
            elif level_up == 'def':
-               player.fighter.defense += 1
+               player.fighter.base_defense += 1
        ...
        ...
        if level_up:
            if level_up == 'hp':
                player.fighter.max_hp += 20
                player.fighter.base_max_hp += 20
                player.fighter.hp += 20
            elif level_up == 'str':
                player.fighter.power += 1
                player.fighter.base_power += 1
            elif level_up == 'def':
                player.fighter.defense += 1
                player.fighter.base_defense += 1
        ...

Ceci étant en place, mettons de l’équipement sur la carte ! Ouvrez game_map.py et modifiez la fonction place_entities pour ajouter de l’équipement dans la donjon. Souvenvez-vous d’importer les composants nécessaire en haut.

...
from components.ai import BasicMonster
+from components.equipment import EquipmentSlots
+from components.equippable import Equippable
from components.fighter import Fighter
...
...
from components.ai import BasicMonster
from components.equipment import EquipmentSlots
from components.equippable import Equippable
from components.fighter import Fighter
...
       item_chances = {
            'healing_potion': 35,
+           'sword': from_dungeon_level([[5, 4]], self.dungeon_level),
+           'shield': from_dungeon_level([[15, 8]], self.dungeon_level),
            '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)
        }
       item_chances = {
            'healing_potion': 35,
            'sword': from_dungeon_level([[5, 4]], self.dungeon_level),
            'shield': from_dungeon_level([[15, 8]], self.dungeon_level),
            '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)
        }
               ...
                if item_choice == 'healing_potion':
                    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 == 'sword':
+                   equippable_component = Equippable(EquipmentSlots.MAIN_HAND, power_bonus=3)
+                   item = Entity(x, y, '/', libtcod.sky, 'Sword', equippable=equippable_component)
+               elif item_choice == 'shield':
+                   equippable_component = Equippable(EquipmentSlots.OFF_HAND, defense_bonus=1)
+                   item = Entity(x, y, '[', libtcod.darker_orange, 'Shield', equippable=equippable_component)
                elif item_choice == 'fireball_scroll':
                    ...
               ...
                if item_choice == 'healing_potion':
                    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 == 'sword':
                    equippable_component = Equippable(EquipmentSlots.MAIN_HAND, power_bonus=3)
                    item = Entity(x, y, '/', libtcod.sky, 'Sword', equippable=equippable_component)
                elif item_choice == 'shield':
                    equippable_component = Equippable(EquipmentSlots.OFF_HAND, defense_bonus=1)
                    item = Entity(x, y, '[', libtcod.darker_orange, 'Shield', equippable=equippable_component)
                elif item_choice == 'fireball_scroll':
                    ...

Une chose qu’on peut faire pour rendre le jeu plus intéressant est de donner une arme de base au joueur. Rien de trop puissant, bien sûr, c’est un roguelike après tout. Modifions la fonction get_game_variables dans initialize_new_game.py pour donne au joueur une dague au début de partie.

import tcod as libtcod

from components.equipment import Equipment
+from components.equippable import Equippable
from components.fighter import Fighter
from components.inventory import Inventory
from components.level import Level

from entity import Entity

+from equipment_slots import EquipmentSlots

from game_messages import MessageLog
...

def get_game_variables(constants):
-   fighter_component = Fighter(hp=100, defense=1, power=4)
+   fighter_component = Fighter(hp=100, defense=1, power=2)
    inventory_component = Inventory(26)
    level_component = Level()
    equipment_component = Equipment()
    player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
                    fighter=fighter_component, inventory=inventory_component, level=level_component,
                    equipment=equipment_component)
    entities = [player]

+   equippable_component = Equippable(EquipmentSlots.MAIN_HAND, power_bonus=2)
+   dagger = Entity(0, 0, '-', libtcod.sky, 'Dagger', equippable=equippable_component)
+   player.inventory.add_item(dagger)
+   player.equipment.toggle_equip(dagger)

    game_map = GameMap(constants['map_width'], constants['map_height'])
    ...
import tcod as libtcod

from components.equipment import Equipment
from components.equippable import Equippable
from components.fighter import Fighter
from components.inventory import Inventory
from components.level import Level

from entity import Entity

from equipment_slots import EquipmentSlots

from game_messages import MessageLog
...

def get_game_variables(constants):
    fighter_component = Fighter(hp=100, defense=1, power=4)
    fighter_component = Fighter(hp=100, defense=1, power=2)
    inventory_component = Inventory(26)
    level_component = Level()
    equipment_component = Equipment()
    player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
                    fighter=fighter_component, inventory=inventory_component, level=level_component,
                    equipment=equipment_component)
    entities = [player]

    equippable_component = Equippable(EquipmentSlots.MAIN_HAND, power_bonus=2)
    dagger = Entity(0, 0, '-', libtcod.sky, 'Dagger', equippable=equippable_component)
    player.inventory.add_item(dagger)
    player.equipment.toggle_equip(dagger)

    game_map = GameMap(constants['map_width'], constants['map_height'])
    ...

Remarquez qu’on modifie aussi la puissance de départ. Nous ne voulons pas que le joueur soit trop fort au départ !

Une dernière amélioration à ajouter : affichons dans l’inventaire l’équipement porté par le joueur. Nous pouvons le faire en modifiant la fonction inventory_menu dans menus.py pour vérifier si chaque objet est équipé ou non. Nous devrons faire quelques chanchements dans les arguments de la fonction, nous devons passer player et non plus seulement l’inventaire. Modifiez la fonction ainsi :

-def inventory_menu(con, header, inventory, inventory_width, screen_width, screen_height):
+def inventory_menu(con, header, player, inventory_width, screen_width, screen_height):
-   if len(inventory.items) == 0:
+   if len(player.inventory.items) == 0:
        options = ['Inventory is empty.']
    else:
-       options = [item.name for item in inventory.items]
+       options = []
+
+       for item in player.inventory.items:
+           if player.equipment.main_hand == item:
+               options.append('{0} (on main hand)'.format(item.name))
+           elif player.equipment.off_hand == item:
+               options.append('{0} (on off hand)'.format(item.name))
+           else:
+               options.append(item.name)

    menu(con, header, options, inventory_width, screen_width, screen_height)
def inventory_menu(con, header, inventory, inventory_width, screen_width, screen_height):
def inventory_menu(con, header, player, inventory_width, screen_width, screen_height):
    if len(inventory.items) == 0:
    if len(player.inventory.items) == 0:
        options = ['Inventory is empty.']
    else:
        options = [item.name for item in inventory.items]
        options = []

        for item in player.inventory.items:
            if player.equipment.main_hand == item:
                options.append('{0} (on main hand)'.format(item.name))
            elif player.equipment.off_hand == item:
                options.append('{0} (on off hand)'.format(item.name))
            else:
                options.append(item.name)

    menu(con, header, options, inventory_width, screen_width, screen_height)

Parce que nous avons changé les arguments de cette fonction nous devons adapter son appel dans render_all.

        ...
            inventory_title = 'Press the key next to an item to drop it, or Esc to cancel.\n'

-       inventory_menu(con, inventory_title, player.inventory, 50, screen_width, screen_height)
+       inventory_menu(con, inventory_title, player, 50, screen_width, screen_height)

    elif game_state == GameStates.LEVEL_UP:
        ...
        ...
            inventory_title = 'Press the key next to an item to drop it, or Esc to cancel.\n'

        inventory_menu(con, inventory_title, player.inventory, 50, screen_width, screen_height)
        inventory_menu(con, inventory_title, player, 50, screen_width, screen_height)

    elif game_state == GameStates.LEVEL_UP:
        ...

With that, we have a functional equipment system! This concludes the main tutorial. If you’re wanting more, feel free to check out the extras section, which I’ll try to update now and then with some new content. Now go forth, and create the roguelike of your dreams!

Avec ça, nous avons un système d’équiment qui fonctionne ! Cela conclut le tutoriel principal. Si vous en voulez davantage, n’hésitez pas à vérifier les extras que j’essayerai de mettre à jour de temps en temps. Maintenant continuez sur votre lancée et créez le roguelike de vos rêves.

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