Part 7 - Créer une interface


Avec chaque chapitre notre jeu est un peu plus jouable mais avant de progresser sur le gameplay, nous devrions prendre un moment pour nous concentrer sur l’aspect esthétique. Contrairement à ce que les traditionalistes du roguelike pourraient vous dire, une bonne UI est pratique.

Commençons par la section sur l’HP. Avec relativement peu de code nous pouvons ajouter une petite barre de vie qui nous dira combien il reste de santé au joueur avant de mourir. Commençons par ajouter quelques variables utiles dans engine.py

    ...
    screen_height = 50

+   bar_width = 20
+   panel_height = 7
+   panel_y = screen_height - panel_height

    map_width = 80
-   map_height = 45
+   map_height = 43
    ...
    con = libtcod.console_new(screen_width, screen_height)
+   panel = libtcod.console_new(screen_width, panel_height)
    ...
    screen_height = 50

    bar_width = 20
    panel_height = 7
    panel_y = screen_height - panel_height

    map_width = 80
    map_height = 45
    map_height = 43
    ...
    con = libtcod.console_new(screen_width, screen_height)
    panel = libtcod.console_new(screen_width, panel_height)

Nous créons une nouvelle console, panel, qui contiendra notre barre de vie et le journal des messages. Nous avons aussi modifié la hauteur de la carte afin de laisser un peu de place à notre barre de vie et notre futur journal de message.

Maintenant il nous faut une faut une fonction qui dessine la barre de vie et n’importe quelle barre qu’on puisse souhaiter. Vous pouvez ajouter une barre de Mana ou de Stamina plus tard si vous le souhaitez aussi il est préférable de la rendre la plus réutilisable possible. Ajoutez la suite à render_functions.py, juste en dessous de l’enum RenderOrder mais au dessus de render_all.

def render_bar(panel, x, y, total_width, name, value, maximum, bar_color, back_color):
    bar_width = int(float(value) / maximum * total_width)

    libtcod.console_set_default_background(panel, back_color)
    libtcod.console_rect(panel, x, y, total_width, 1, False, libtcod.BKGND_SCREEN)

    libtcod.console_set_default_background(panel, bar_color)
    if bar_width > 0:
        libtcod.console_rect(panel, x, y, bar_width, 1, False, libtcod.BKGND_SCREEN)

    libtcod.console_set_default_foreground(panel, libtcod.white)
    libtcod.console_print_ex(panel, int(x + total_width / 2), y, libtcod.BKGND_NONE, libtcod.CENTER,
                             '{0}: {1}/{2}'.format(name, value, maximum))

Maintenant utilisons cette fonction dans render_all. Retirez l’indication de HP qu’on a ajouté plus tôt et ajoutez le code pour le panneau de statistiques à la fin de la fonction.

-def render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
+def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, bar_width,
+              panel_height, panel_y, colors):
            ...
-   libtcod.console_set_default_foreground(con, libtcod.white)
-   libtcod.console_print_ex(con, 1, screen_height - 2, libtcod.BKGND_NONE, libtcod.LEFT,
-                            'HP: {0:02}/{1:02}'.format(player.fighter.hp, player.fighter.max_hp))

    libtcod.console_blit(con, 0, 0, screen_width, screen_height, 0, 0, 0)

+   libtcod.console_set_default_background(panel, libtcod.black)
+   libtcod.console_clear(panel)
+
+   render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
+              libtcod.light_red, libtcod.darker_red)
+
+   libtcod.console_blit(panel, 0, 0, screen_width, panel_height, 0, 0, panel_y)
def render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, bar_width,
               panel_height, panel_y, colors):
            ...
    libtcod.console_set_default_foreground(con, libtcod.white)
    libtcod.console_print_ex(con, 1, screen_height - 2, libtcod.BKGND_NONE, libtcod.LEFT,
                             'HP: {0:02}/{1:02}'.format(player.fighter.hp, player.fighter.max_hp))

    libtcod.console_blit(con, 0, 0, screen_width, screen_height, 0, 0, 0)

    libtcod.console_set_default_background(panel, libtcod.black)
    libtcod.console_clear(panel)

    render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
               libtcod.light_red, libtcod.darker_red)

    libtcod.console_blit(panel, 0, 0, screen_width, panel_height, 0, 0, panel_y)

Ajoutez l’appel à render_all dans engine.py.

-       render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors)
+       render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height,
+                  bar_width, panel_height, panel_y, colors)
        render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors)
        render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height,
                   bar_width, panel_height, panel_y, colors)

Maintenant on a une jolie barre de vie en bas de l’écran. Elle va décroître quand le joueur perd des HP et croître quand on se soigne (ça arrive au prochain chapitre).

Continuons d’avancer et créons un journal de messages. Ajouter les variables suivantes à engine.py :

    ...
    panel_y = screen_height - panel_height

+   message_x = bar_width + 2
+   message_width = screen_width - bar_width - 2
+   message_height = panel_height - 1

    map_width = 80
    ...
    ...
    panel_y = screen_height - panel_height

    message_x = bar_width + 2
    message_width = screen_width - bar_width - 2
    message_height = panel_height - 1

    map_width = 80
    ...

Pour implémenter un journal de message, nous avons besoin de deux classes : une pour le journal et une pour les messages qu’il contient. Commencez par créer un nouveau fichier appelé game_messages.py. Ajoutez-y le code suivant :

import tcod as libtcod

import textwrap


class Message:
    def __init__(self, text, color=libtcod.white):
        self.text = text
        self.color = color


class MessageLog:
    def __init__(self, x, width, height):
        self.messages = []
        self.x = x
        self.width = width
        self.height = height

    def add_message(self, message):
        # Split the message if necessary, among multiple lines
        new_msg_lines = textwrap.wrap(message.text, self.width)

        for line in new_msg_lines:
            # If the buffer is full, remove the first line to make room for the new one
            if len(self.messages) == self.height:
                del self.messages[0]

            # Add the new line as a Message object, with the text and the color
            self.messages.append(Message(line, message.color))

Cela fait beaucoup aussi examinons en détail.

Message est plutôt simple. On enregistre le message et la couleur associée. Vous pouvez décider de ne pas passer de couleur, auquel cas, le blanc est utilisé par défaut.

La classe MessageLog est la plus intéressante. Elle conserve une liste de messages (de la classe Message), conserve les coordonnées en x (par commodité) et sans hauteur et largeur. Hauteur et largeur sont pratiques pour savoir quand couper le message du haut (les messages vont “défiler” avec l’arrivée de nouveaux messages).

Dans la méthode add_message, on sépare le texte des messages en de multiples lignes si c’est nécessaire, en utilisant la fonction textwrap.wrap. On peut ensuite vérifier si le message et rempli et, si nécessaire, on efface la ligne du haut. Enfin, on ajoute le nouveau message.

Commençons par mettre en place le nouveau journal de messages. Ajoutez un nouveau journal à engine.py :

    fov_map = initialize_fov(game_map)

+   message_log = MessageLog(message_x, message_width, message_height)

    key = libtcod.Key()
    fov_map = initialize_fov(game_map)

    message_log = MessageLog(message_x, message_width, message_height)

    key = libtcod.Key()

Souvenez-vous d’importer aussi le MessaegLog en haut :

from fov_functions import initialize_fov, recompute_fov
+from game_messages import MessageLog
from game_states import GameStates
from fov_functions import initialize_fov, recompute_fov
from game_messages import MessageLog
from game_states import GameStates

Notre journal de message étant implémenté, parcourons le projet pour retirer les expressions print et les remplacer par des messages log.

Commençons par les fonctions ‘death’. Dans death_functions.py :

import tcod as libtcod

+from game_messages import Message

from game_states import GameStates

from render_functions import RenderOrder


def kill_player(player):
    player.char = '%'
    player.color = libtcod.dark_red

-   return 'You died!', GameStates.PLAYER_DEAD
+   return Message('You died!', libtcod.red), GameStates.PLAYER_DEAD


def kill_monster(monster):
-   death_message = '{0} is dead!'.format(monster.name.capitalize())
+   death_message = Message('{0} is dead!'.format(monster.name.capitalize()), libtcod.orange)
    ...
import tcod as libtcod

from game_messages import Message

from game_states import GameStates

from render_functions import RenderOrder


def kill_player(player):
    player.char = '%'
    player.color = libtcod.dark_red

    return 'You died!', GameStates.PLAYER_DEAD
    return Message('You died!', libtcod.red), GameStates.PLAYER_DEAD


def kill_monster(monster):
    death_message = '{0} is dead!'.format(monster.name.capitalize())
    death_message = Message('{0} is dead!'.format(monster.name.capitalize()), libtcod.orange)
    ...

Ensuite, revenez dans engine.py et remplacez les expressions print comme ceci :

             ...
            (In the player's results loop)
            ...
            if dead_entity:
                if dead_entity == player:
                    message, game_state = kill_player(dead_entity)
                else:
                    message = kill_monster(dead_entity)

-               print(message)
+               message_log.add_message(message)
            ...
            (In the enemy results loop)
            ...
                        if dead_entity:
                            if dead_entity == player:
                                message, game_state = kill_player(dead_entity)
                            else:
                                message = kill_monster(dead_entity)

-                           print(message)
+                           message_log.add_message(message)
                            ...
             ...
            (In the player's results loop)
            ...
            if dead_entity:
                if dead_entity == player:
                    message, game_state = kill_player(dead_entity)
                else:
                    message = kill_monster(dead_entity)

                print(message)
                message_log.add_message(message)
            ...
            (In the enemy results loop)
            ...
                        if dead_entity:
                            if dead_entity == player:
                                message, game_state = kill_player(dead_entity)
                            else:
                                message = kill_monster(dead_entity)

                            print(message)
                            message_log.add_message(message)
                            ...

Maintenant pour nos messages d’action, dans fighter.py :

        ...
        if damage > 0:
-           results.append({'message': '{0} attacks {1} for {2} hit points.'.format(self.owner.name.capitalize(),
-                                                                                   target.name, str(damage))})
+           results.append({'message': Message('{0} attacks {1} for {2} hit points.'.format(
+               self.owner.name.capitalize(), target.name, str(damage)), libtcod.white)})
            results.extend(target.fighter.take_damage(damage))
        else:
-           results.append({'message': '{0} attacks {1} but does no damage.'.format(self.owner.name.capitalize(),
-                                                                                   target.name)})
+           results.append({'message': Message('{0} attacks {1} but does no damage.'.format(
+               self.owner.name.capitalize(), target.name), libtcod.white)})

        return results
        ...
        if damage > 0:
            results.append({'message': '{0} attacks {1} for {2} hit points.'.format(self.owner.name.capitalize(),
                                                                                    target.name, str(damage))})
            results.append({'message': Message('{0} attacks {1} for {2} hit points.'.format(
                self.owner.name.capitalize(), target.name, str(damage)), libtcod.white)})
            results.extend(target.fighter.take_damage(damage))
        else:
            results.append({'message': '{0} attacks {1} but does no damage.'.format(self.owner.name.capitalize(),
                                                                                    target.name)})
            results.append({'message': Message('{0} attacks {1} but does no damage.'.format(
                self.owner.name.capitalize(), target.name), libtcod.white)})

        return results

Vous devez importer à la fois libtcod et Message pour que cela fonctionne :

+import tcod as libtcod

+from game_messages import Message


class Fighter:
    ...
import tcod as libtcod

from game_messages import Message


class Fighter:
    ...

Et dans engine.py :

            ...
            (In the player's results loop)
            ...
            if message:
-               print(message)
+               message_log.add_message(message)
            ...
            (In the enemy results loop)
            ...
                        if message:
-                           print(message)
+                           message_log.add_message(message)
            ...
            (In the player's results loop)
            ...
            if message:
                print(message)
                message_log.add_message(message)
            ...
            (In the enemy results loop)
            ...
                        if message:
                            print(message)
                            message_log.add_message(message)

Super, maintenant nous ajoutons tous les messages dans le log. Cela étant dit, rien n’apparaît encore. Modifions render_all pour afficher le journal de message que nous avons crée.

-def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, bar_width,
-              panel_height, panel_y, colors):
+def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width, screen_height,
+              bar_width, panel_height, panel_y, colors):
    ...
    libtcod.console_clear(panel)

+   # Print the game messages, one line at a time
+   y = 1
+   for message in message_log.messages:
+       libtcod.console_set_default_foreground(panel, message.color)
+       libtcod.console_print_ex(panel, message_log.x, y, libtcod.BKGND_NONE, libtcod.LEFT, message.text)
+       y += 1
    ...
def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, bar_width,
               panel_height, panel_y, colors):
def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width, screen_height,
               bar_width, panel_height, panel_y, colors):
    ...
    libtcod.console_clear(panel)

    # Print the game messages, one line at a time
    y = 1
    for message in message_log.messages:
        libtcod.console_set_default_foreground(panel, message.color)
        libtcod.console_print_ex(panel, message_log.x, y, libtcod.BKGND_NONE, libtcod.LEFT, message.text)
        y += 1
    ...

Modifiez l’appel à render_all depuis engine.py pour inclure le journal de message :

-       render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height,
-                  bar_width, panel_height, panel_y, colors)
+       render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width,
+                  screen_height, bar_width, panel_height, panel_y, colors)
        render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height,
                   bar_width, panel_height, panel_y, colors)
        render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width,
                   screen_height, bar_width, panel_height, panel_y, colors)

Lancez le projet maintenant. Tous les anciens “print” devraient apparaître dans un journal de message déroulant. Dorenavent nous n’utiliserons plus de print, nous ajouterons tout à notre journal de messages.

Et la suite ? Pourquoi pas un peu d’action à la souris ? Notre jeu ne contient que des orcs et des trolls pour l’instant mais peut-être qu’un jour nous aurons des douzaines (centaines ?) de monstres différents et de types d’objets. Ce serait bien qu’on puisse voir ce qu’ils sont en déplaçant la souris sur eux.

Heureusement pour nous, on a déjà capturé les événements souris dans la variable mouse juste au dessus de la boucle principale. Tout ce qu’il faut faire est d’ajuster notre appel à libtcod.sys_check_for_event pour répondre à la souris et d’écrire le code qui affiche le nom quand on déplace la souris au dessus de quelquechose.

-       libtcod.sys_check_for_event(libtcod.EVENT_KEY_PRESS, key, mouse)
+       libtcod.sys_check_for_event(libtcod.EVENT_KEY_PRESS | libtcod.EVENT_MOUSE, key, mouse)
        libtcod.sys_check_for_event(libtcod.EVENT_KEY_PRESS, key, mouse)
        libtcod.sys_check_for_event(libtcod.EVENT_KEY_PRESS | libtcod.EVENT_MOUSE, key, mouse)

Ajoutez la fonction suivante dans render_functions.py au dessus de render_bar :

+def get_names_under_mouse(mouse, entities, fov_map):
+   (x, y) = (mouse.cx, mouse.cy)

+   names = [entity.name for entity in entities
+            if entity.x == x and entity.y == y and libtcod.map_is_in_fov(fov_map, entity.x, entity.y)]
+   names = ', '.join(names)

+   return names.capitalize()


def render_bar(panel, x, y, total_width, name, value, maximum, bar_color, back_color):
    ...
def get_names_under_mouse(mouse, entities, fov_map):
    (x, y) = (mouse.cx, mouse.cy)

    names = [entity.name for entity in entities
             if entity.x == x and entity.y == y and libtcod.map_is_in_fov(fov_map, entity.x, entity.y)]
    names = ', '.join(names)

    return names.capitalize()


def render_bar(panel, x, y, total_width, name, value, maximum, bar_color, back_color):
    ...

Maintenant nous allons à nouveau modifier notre fonction render_all (elle a beaucoup changé dans ce chapitre, n’est-ce-pas ?) pour utiliser la souris et utiliser notre nouvelle fonction.

-def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width, screen_height,
-              bar_width, panel_height, panel_y, colors):
+def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width, screen_height,
+              bar_width, panel_height, panel_y, mouse, colors):
    ...
    render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
               libtcod.light_red, libtcod.darker_red)

+   libtcod.console_set_default_foreground(panel, libtcod.light_gray)
+   libtcod.console_print_ex(panel, 1, 0, libtcod.BKGND_NONE, libtcod.LEFT,
+                            get_names_under_mouse(mouse, entities, fov_map))

    libtcod.console_blit(panel, 0, 0, screen_width, panel_height, 0, 0, panel_y)
def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width, screen_height,
               bar_width, panel_height, panel_y, colors):
def render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width, screen_height,
               bar_width, panel_height, panel_y, mouse, colors):
    ...
    render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
               libtcod.light_red, libtcod.darker_red)

    libtcod.console_set_default_foreground(panel, libtcod.light_gray)
    libtcod.console_print_ex(panel, 1, 0, libtcod.BKGND_NONE, libtcod.LEFT,
                             get_names_under_mouse(mouse, entities, fov_map))

    libtcod.console_blit(panel, 0, 0, screen_width, panel_height, 0, 0, panel_y)

Et, bien-sûr, nous devons modifier l’appel à render_all dans engine.py pour correspondre à notre nouvelle définition.

-       render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width,
-                  screen_height, bar_width, panel_height, panel_y, colors)
+       render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width,
+                  screen_height, bar_width, panel_height, panel_y, mouse, colors)
        render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width,
                   screen_height, bar_width, panel_height, panel_y, colors)
        render_all(con, panel, entities, player, game_map, fov_map, fov_recompute, message_log, screen_width,
                   screen_height, bar_width, panel_height, panel_y, mouse, colors)

Les graphismes de notre jeu sont bien meilleurs. Si vous espérez que votre jeu soit joué par d’autres joueurs que vous (sinon c’est bien aussi !) ce type de changement sera d’une grande importance dans votre projet.

Si vous voulez voir le code actuel entièrement, cliquez ici.

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