bear_hug: jeux en art ASCII en Python3.6 +



Pour mes jeux en art ASCII, j'ai écrit la bibliothèque bear_hug avec une file d'attente d'événements, une collection de widgets, le support ECS et d'autres petites choses utiles. Dans cet article, nous verrons comment l'utiliser pour créer un jeu minimal.

Avertissements
  • Je suis le seul développeur de la bibliothèque, donc je peux être partial.
  • bear_hug est essentiellement un wrapper autour de bearlibterminal , il n'y aura donc pas d'opérations de niveau relativement bas avec les glyphes.
  • Il existe une fonctionnalité similaire dans clubsandwich , mais je ne l'ai pas utilisée et je ne peux pas la comparer.

Sous le capot de bear_hug se trouve bearlibterminal , une bibliothèque SDL pour créer une fenêtre pseudo-console. Autrement dit, en ATS pur, comme certains ncurses, cela ne fonctionnera pas. Mais alors l'image est la même sous Linux, sous Windows et ne dépend pas des paramètres du terminal utilisateur. Ceci est important, en particulier pour les jeux, car lorsque vous changez la police ASCII-art, Dieu peut bien se transformer en:


Le même dessin dans sa forme originale et après copier-coller dans différents programmes

Bien sûr, la bibliothèque a été écrite pour des projets de relativement grande envergure. Mais afin de ne pas être distrait par la conception et l'architecture du jeu, dans cet article, nous allons créer quelque chose de simple. Un projet d'une nuit, dans lequel il y a quelque chose pour montrer les fonctions de base de la bibliothèque. A savoir - un clone simplifié de ces mêmes chars avec Dandy (ils sont également Battle City). Il y aura un char de joueur, des chars ennemis, des murs destructibles, du son et des scores. Mais le menu principal, les niveaux et les bonus sélectionnés ne le seront pas. Non pas parce qu'il serait impossible de les ajouter, mais parce que ce projet n'est rien d'autre qu'un Halloworld.


Il y aura une partie, mais il n'y aura pas de victoire. Parce que la vie est douleur.

Tout le matériel utilisé dans l'article est sur le github ; la bibliothèque elle-même est également sur PyPI (sous licence MIT).

Tout d'abord, nous avons besoin d'actifs. Pour dessiner de l'art ASCII, j'utilise REXpaint de Josh Ge (alias Kyzrati), le développeur du bagel de science-fiction Cogmind . L'éditeur est gratuit, mais pas open source; la version officielle est uniquement pour Windows, mais tout fonctionne bien sous wine. L'interface est assez claire et pratique:



Nous enregistrons au format binaire local .xp et copions de /path/to/rexpaint/images dans le dossier avec le futur jeu. En principe, le chargement d'images à partir de fichiers .txt est également pris en charge, mais il est évidemment impossible d'enregistrer les couleurs des caractères individuels dans un fichier texte. Oui, et l'édition d'art ASCII dans un cahier ne me convient pas personnellement. Afin de ne pas coder en dur les coordonnées et la taille de chaque élément, ces données sont stockées dans un fichier JSON distinct:

battlecity.json
 [ { "name": "player_r", "x": 1, "y": 2, "xsize": 6, "ysize": 6 }, { "name": "player_l", "x": 1, "y": 8, "xsize": 6, "ysize": 6 }, ... ] 


Sons sous licences gratuites téléchargeables sur Internet. Jusqu'à présent, seul .wav est pris en charge. C'est tout avec des actifs, vous pouvez commencer à coder. Tout d'abord, vous devez initialiser le terminal et la file d'attente des événements.

game.py
 #  terminal = BearTerminal(font_path='cp437_12x12.png', size='91x60', title='AsciiCity', filter=['keyboard', 'mouse']) #   dispatcher = BearEventDispatcher() # ,         loop = BearLoop(terminal, dispatcher) 


Le terminal est la fenêtre réelle du jeu. Vous pouvez placer des widgets dessus, et il lance des événements d'entrée si nécessaire. En tant que clés lors de la création d'un terminal, vous pouvez utiliser toutes les options du terminal bearlibterminal ; dans ce cas, nous définissons la police, la taille de la fenêtre (en caractères), le titre de la fenêtre et les méthodes de saisie qui nous intéressent.

Quant à la file d'attente d'événements, elle a une interface très simple: dispatcher.add_event (événement) ajoute l'événement à la file d'attente, et dispatcher.register_listener (écouteur, event_types) vous permet de vous y abonner. Un signataire (par exemple, un widget ou un composant) doit avoir un rappel on_event, qui prend un événement comme argument unique et ne renvoie rien ou renvoie un autre événement ou un ensemble d'événements. L'événement lui-même se compose de type et de valeur; le type ici n'est pas dans le sens de str ou int, mais dans le sens de «variété», par exemple, «key_down» ou «tick». La file d'attente accepte uniquement les événements de types qui lui sont connus (intégrés ou créés par l'utilisateur) et les envoie à on_event tous ceux qui souscrivent à ce type. Il ne vérifie en aucune façon les valeurs, mais il existe des conventions au sein de la bibliothèque sur ce qui est une valeur valide pour chaque type d'événement.

Tout d'abord, nous mettons en file d'attente quelques auditeurs. Il s'agit de la classe de base pour les objets qui peuvent s'abonner à des événements, mais qui ne sont pas des widgets ou des composants. En principe, il n'est pas nécessaire de l'utiliser, tant que le signataire dispose de la méthode on_event.

Auditeurs
 #       dispatcher.register_listener(ClosingListener(), ['misc_input', 'tick']) #       dispatcher.register_listener(EntityTracker(), ['ecs_create', 'ecs_destroy']) #   jukebox = SoundListener({'shot': 'shot.wav', 'explosion': 'explosion.wav'}) # https://freesound.org/people/EMSIarma/sounds/108852/ # https://freesound.org/people/FlashTrauma/sounds/398283/ dispatcher.register_listener(jukebox, 'play_sound') 


Une liste complète des types d'événements intégrés se trouve dans la documentation . Il est facile de voir qu'il y a des événements pour la création et la destruction d'entités, mais pas pour les dommages. Puisque nous aurons des objets qui ne se séparent pas d'un seul coup (les murs et le réservoir du joueur), nous allons le créer:

Inscription au type d'événement
 #      ,    #   dispatcher.register_event_type('ac_damage') #       logger = LoggingListener(sys.stderr) dispatcher.register_listener(logger, ['ac_damage', 'play_sound']) 


Nous convenons que, en tant que valeur, cet événement aura un tuple à partir de l'ID de l'entité qui a subi le dommage et de la valeur du dommage. LoggingListener est juste un outil de débogage qui imprime tous les événements reçus où qu'ils disent, dans ce cas dans stderr. Dans ce cas, je voulais m'assurer que les dégâts passent correctement, et que le son est toujours demandé quand il le devrait.

Avec Listeners pour l'instant, vous pouvez ajouter le premier widget. Nous avons ce terrain de jeu de classe ECSLayout. Il s'agit d'une telle disposition, qui peut placer des widgets sur des entités et les déplacer en réponse à des événements ecs_move, tout en tenant compte des collisions. Comme la plupart des widgets, il a deux arguments requis: une liste imbriquée de caractères (éventuellement vide - espace ou Aucun) et une liste imbriquée de couleurs pour chaque caractère. Les couleurs nommées sont acceptées en tant que couleurs, RVB au format `0xAARRGGBB` (ou` 0xARGB`, `0xRGB`,` 0xRRGGBB`) et au format '#fff'. Les tailles des deux listes doivent correspondre; sinon, une exception est levée.

Premier widget
 #   .  8460,   9160. # 7         chars = [[' ' for x in range(84)] for y in range(60)] colors = copy_shape(chars, 'gray') layout = ECSLayout(chars, colors) # 'all' -  ,      dispatcher.register_listener(layout, 'all') 


Puisque nous avons maintenant sur quoi placer les objets dans le jeu, nous pouvons commencer à créer des entités. Tout le code des entités et des composants est déplacé vers un fichier distinct. Le plus simple d'entre eux est un mur de briques destructible. Elle sait se trouver à un certain endroit, afficher son widget, servir d'objet de collision et subir des dégâts. Après suffisamment de dégâts, le mur disparaît.

entity.py
 def create_wall(dispatcher, atlas, entity_id, x, y): #   wall = Entity(entity_id) #  wall.add_component(PositionComponent(dispatcher, x, y)) wall.add_component(CollisionComponent(dispatcher)) wall.add_component(PassingComponent(dispatcher)) wall.add_component(DestructorComponent(dispatcher)) #     ,      #    -  /   images_dict = {'wall_3': atlas.get_element('wall_3'), 'wall_2': atlas.get_element('wall_2'), 'wall_1': atlas.get_element('wall_1')} wall.add_component(SwitchWidgetComponent(dispatcher, SwitchingWidget(images_dict=images_dict, initial_image='wall_3'))) wall.add_component(VisualDamageHealthComponent(dispatcher, hitpoints=3, widgets_dict={3: 'wall_3', 2: 'wall_2', 1: 'wall_1'})) #     dispatcher.add_event(BearEvent('ecs_create', wall)) dispatcher.add_event(BearEvent('ecs_add', (wall.id, wall.position.x, wall.position.y))) 


Tout d'abord, l'objet entité lui-même est créé. Il ne contient qu'un nom (qui doit être unique) et un ensemble de composants. Ils peuvent être transférés en une seule fois lors de la création ou, comme ici, ajoutés un par un. Ensuite, tous les composants nécessaires sont créés. En tant que widget, SwitchWidget est utilisé, qui contient plusieurs dessins de la même taille et peut les changer par commande. Soit dit en passant, les dessins sont chargés à partir de l'atlas lors de la création d'un widget. Et, enfin, l'annonce de la création de l'entité et l'ordre de la dessiner sur les coordonnées nécessaires vont dans la file d'attente.

Parmi les composants non intégrés ici, uniquement la santé. J'ai créé la classe de base «Composant de santé» et en ai hérité le «widget de changement de composant de santé» (pour montrer le mur intact et à plusieurs stades de destruction).

class HealthComponent
 class HealthComponent(Component): def __init__(self, *args, hitpoints=3, **kwargs): super().__init__(*args, name='health', **kwargs) self.dispatcher.register_listener(self, 'ac_damage') self._hitpoints = hitpoints def on_event(self, event): if event.event_type == 'ac_damage' and event.event_value[0] == self.owner.id: self.hitpoints -= event.event_value[1] @property def hitpoints(self): return self._hitpoints @hitpoints.setter def hitpoints(self, value): if not isinstance(value, int): raise BearECSException( f'Attempting to set hitpoints of {self.owner.id} to non-integer {value}') self._hitpoints = value if self._hitpoints < 0: self._hitpoints = 0 self.process_hitpoint_update() def process_hitpoint_update(self): """ Should be overridden by child classes. """ raise NotImplementedError('HP update processing should be overridden') def __repr__(self): #           return dumps({'class': self.__class__.__name__, 'hitpoints': self.hitpoints}) 


Lors de la création d'un composant, la clé 'nom' est passée à super () .__ init__. Lorsqu'un composant est ajouté à une entité, sous le nom de cette clé, il sera ajouté au __dict__ de l'entité et il est accessible via entity_object.health. En plus de la commodité de l'interface, cette approche est bonne car elle interdit l'émission d'entités de plusieurs composants homogènes. Et le fait qu'il soit codé en dur à l'intérieur du composant ne vous permet pas d'insérer par erreur, par exemple, WidgetComponent dans l'emplacement du composant de santé. Immédiatement après sa création, le composant souscrit aux classes d'événements qui l'intéressent, dans ce cas ac_damage. Après avoir reçu un tel événement, la méthode on_event vérifiera s'il s'agit de son propriétaire pendant une heure. Si c'est le cas, il soustraira la valeur souhaitée des points de vie et tirera le rappel pour changer la santé, la classe de base est abstraite. Il existe également la méthode __repr__, qui est utilisée pour la sérialisation en JSON (par exemple, pour l'enregistrement). Il n'est pas nécessaire de l'ajouter, mais tous les composants intégrés et la plupart des widgets intégrés l'ont.

L'héritage du composant d'intégrité sous-jacent de VisualDamageHealthComponent remplace le rappel sur le changement d'intégrité:

classe VisualDamageHealthComponent
 class VisualDamageHealthComponent(HealthComponent): """       .    HP=0 """ def __init__(self, *args, widgets_dict={}, **kwargs): super().__init__(*args, **kwargs) self.widgets_dict = OrderedDict() for x in sorted(widgets_dict.keys()): self.widgets_dict[int(x)] = widgets_dict[x] def process_hitpoint_update(self): if self.hitpoints == 0 and hasattr(self.owner, 'destructor'): self.owner.destructor.destroy() for x in self.widgets_dict: if self.hitpoints >= x: self.owner.widget.switch_to_image(self.widgets_dict[x]) 


Alors que la santé est supérieure à 0, il demande au composant responsable du widget de dessiner le mur dans l'état souhaité. Ici, l'appel décrit ci-dessus est utilisé via l'attribut de l'objet entité. Une fois les repères terminés, le composant responsable de la destruction correcte de l'entité et tous les composants seront appelés de la même manière.

Pour les autres entités, tout est similaire, seul l'ensemble des composants est différent. Des chars sont ajoutés avec des contrôleurs (entrée pour le joueur, IA pour les adversaires) et des widgets rotatifs, pour les obus - un composant de collision qui endommage ceux qu'ils touchent. Je n'analyserai pas chacun d'eux, car il est volumineux et plutôt trivial; regardez seulement le collisionneur de projectiles. Il a une méthode collided_into, appelée lorsque l'entité hôte s'est écrasée sur quelque chose:

collisionneur de composants de balle
 def collided_into(self, entity): if not entity: self.owner.destructor.destroy() elif hasattr(EntityTracker().entities[entity], 'collision'): self.dispatcher.add_event(BearEvent(event_type='ac_damage', event_value=( entity, self.damage))) self.owner.destructor.destroy() 


Pour s'assurer qu'il est vraiment possible d'obtenir une victime (ce qui peut être faux pour, par exemple, des éléments d'arrière-plan), le projectile utilise EntityTracker (). Il s'agit d'un singleton qui suit toutes les entités créées et détruites; à travers lui, vous pouvez obtenir un objet entité par son nom et faire quelque chose avec ses composants. Dans ce cas, il est vérifié que entity.collision (le gestionnaire de collision des victimes) existe.

Maintenant dans le fichier principal du jeu, nous appelons simplement toutes les fonctions nécessaires à la création d'entités:

Retour à game.py
 #  , -     atlas = Atlas(XpLoader('battlecity.xp'), 'battlecity.json') #   create_player_tank(dispatcher, atlas, 30, 50) #    wall_array = [[0 for _ in range(14)], [0 for _ in range(14)], [1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0], [1, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1], [1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0], [1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0], [0 for _ in range(14)], [0 for _ in range(14)], [0 for _ in range(14)] ] for y in range(10): for x in range(14): if wall_array[y][x] == 1: create_wall(dispatcher, atlas, f'wall{x}{y}', x*6, y*6) #   -  .     . create_spawner_house(dispatcher, atlas, 35, 0) 


Les marqueurs de points et de points de vie ne sont pas des entités et ne sont pas sur le champ de bataille. Par conséquent, ils ne sont pas ajoutés à ECSLayout, mais directement au terminal à droite de la carte. Les widgets pertinents héritent de Label (widget de sortie de texte) et disposent d'une méthode on_event pour découvrir ce qui les intéresse. Contrairement à Layout, le terminal ne met pas automatiquement à jour les widgets à chaque tick, donc après avoir changé le texte, les widgets lui disent de faire ceci:

listeners.py
 class ScoreLabel(Label): """   """ def __init__(self, *args, **kwargs): super().__init__(text='Score:\n0') self.score = 0 def on_event(self, event): if event.event_type == 'ecs_destroy' and 'enemy' in event.event_value and 'bullet' not in event.event_value: #    self.score += 10 self.text = f'Score:\n{self.score}' self.terminal.update_widget(self) class HPLabel(Label): """   """ def __init__(self, *args, **kwargs): super().__init__(text='HP:\n5') self.hp = 5 def on_event(self, event): if event.event_type == 'ac_damage' and event.event_value[0] == 'player': self.hp -= event.event_value[1] self.text = f'HP:\n{self.hp}' self.terminal.update_widget(self) 


Le générateur ennemi et l'objet responsable de la sortie de "GAME OVER" ne sont pas affichés du tout, ils héritent donc de Listener. Le principe est le même: les objets écoutent la file d'attente, attendent le bon moment, puis créent une entité ou un widget.

gameover
 #    - . . . class GameOverListener(Listener): """       """ def __init__(self, *args, widget=None, **kwargs): print (args) super().__init__(*args, *kwargs) self.widget = widget def on_event(self, event): if event.event_type == 'ecs_destroy' and event.event_value == 'player': self.terminal.add_widget(self.widget, pos=(20, 20), layer=5) 


Maintenant, nous avons créé tout ce dont nous avons besoin et nous pouvons commencer le jeu.

Nous lançons
 #      ,   Listeners    terminal.start() terminal.add_widget(layout) terminal.add_widget(score, pos=(85, 10)) terminal.add_widget(hp, pos=(85, 15)) 


Les widgets sont ajoutés à l'écran uniquement après son démarrage. Les entités pourraient être ajoutées à la carte avant - les événements de création (dans lesquels l'entité entière est stockée, y compris le widget) sont simplement accumulés dans la file d'attente et résolus au premier tick. Mais le terminal ne peut ajouter des widgets qu'une fois qu'une fenêtre a été créée avec succès pour lui.

À ce stade, nous avons un prototype fonctionnel, que vous pouvez publier dans Early Access pour vingt dollars, ajouter des fonctionnalités et peaufiner le gameplay. Mais cela dépasse déjà le cadre de halloworld, et donc de l'article. J'ajouterai seulement que la construction indépendante du système python peut être construite en utilisant pyinstaller .

Source: https://habr.com/ru/post/fr470950/


All Articles