bear_hug: Spiele in ASCII-Grafik in Python3.6 +



Für meine Spiele in ASCII-Kunst habe ich die Bibliothek bear_hug mit einer Ereigniswarteschlange, einer Sammlung von Widgets, ECS-Unterstützung und anderen nützlichen Kleinigkeiten geschrieben. In diesem Artikel werden wir sehen, wie man damit ein minimal funktionierendes Spiel macht.

Haftungsausschluss
  • Ich bin der einzige Entwickler der Bibliothek, daher kann ich voreingenommen sein.
  • bear_hug ist im Wesentlichen ein Wrapper um bearlibterminal , daher gibt es keine Operationen auf relativ niedriger Ebene mit Glyphen.
  • Es gibt eine ähnliche Funktionalität in Clubsandwich , aber ich habe sie nicht verwendet und kann sie nicht vergleichen.

Unter der Haube von bear_hug befindet sich bearlibterminal , eine SDL-Bibliothek zum Erstellen eines Pseudokonsolenfensters. Das heißt, in reinem TTY wird es wie einige Flüche nicht funktionieren. Aber dann ist das Bild unter Linux und Windows dasselbe und hängt nicht von den Einstellungen des Benutzerterminals ab. Dies ist besonders für Spiele wichtig, denn wenn Sie die Schriftart ASCII-art ändern, kann Gott sich in Folgendes verwandeln:


Dieselbe Zeichnung in ihrer ursprünglichen Form und nach dem Kopieren und Einfügen in verschiedene Programme

Natürlich wurde die Bibliothek für relativ große Projekte geschrieben. Um sich jedoch nicht vom Spieldesign und der Architektur ablenken zu lassen, werden wir in diesem Artikel etwas Einfaches erstellen. Ein One-Night-Projekt, in dem die Grundfunktionen der Bibliothek gezeigt werden. Nämlich - ein vereinfachter Klon derselben Panzer mit Dandy (sie sind auch Battle City). Es gibt einen Spielerpanzer, feindliche Panzer, zerstörbare Mauern, Geräusche und Punkte. Das Hauptmenü, die Levels und die ausgewählten Boni werden jedoch nicht angezeigt. Nicht weil es unmöglich wäre, sie hinzuzufügen, sondern weil dieses Projekt nichts anderes als eine Halloworld ist.


Es wird ein Gameover geben, aber es wird keinen Sieg geben. Weil das Leben Schmerz ist.

Das gesamte im Artikel verwendete Material befindet sich auf dem Github . Die Bibliothek selbst befindet sich ebenfalls auf PyPI (unter der MIT-Lizenz).

Zuallererst brauchen wir Vermögenswerte. Um ASCII-Kunst zu zeichnen, verwende ich REXpaint von Josh Ge (alias Kyzrati), dem Entwickler des Science-Fiction-Bagels Cogmind . Der Editor ist kostenlos, jedoch nicht Open Source. Die offizielle Version ist nur für Windows, aber unter Wein funktioniert alles einwandfrei. Die Oberfläche ist sehr klar und bequem:



Wir speichern im lokalen Binärformat .xp und kopieren mit dem zukünftigen Spiel von /path/to/rexpaint/images in den Ordner. Grundsätzlich wird auch das Laden von Bildern aus TXT-Dateien unterstützt, es ist jedoch offensichtlich unmöglich, die Farben einzelner Zeichen in einer Textdatei zu speichern. Ja, und das Bearbeiten von ASCII-Grafiken in einem Notizbuch ist für mich persönlich nicht bequem. Um die Koordinaten und die Größe jedes Elements nicht fest zu codieren, werden diese Daten in einer separaten JSON-Datei gespeichert:

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


Klingt unter kostenlosen Lizenzen aus dem Internet. Bisher wird nur .wav unterstützt. Das ist alles mit Assets, Sie können mit dem Codieren beginnen. Zunächst müssen Sie das Terminal und die Ereigniswarteschlange initialisieren.

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


Das Terminal ist das eigentliche Fenster des Spiels. Sie können Widgets darauf platzieren und es werden nach Bedarf Eingabeereignisse ausgelöst. Als Schlüssel beim Erstellen eines Terminals können Sie alle Terminaloptionen bearlibterminal verwenden . In diesem Fall legen wir die Schriftart, die Fenstergröße (in Zeichen), den Fenstertitel und die für uns interessanten Eingabemethoden fest.

Die Ereigniswarteschlange verfügt über eine sehr einfache Oberfläche: dispatcher.add_event (Ereignis) fügt das Ereignis der Warteschlange hinzu, und dispatcher.register_listener (Listener, event_types) ermöglicht das Abonnieren. Ein Unterzeichner (z. B. ein Widget oder eine Komponente) muss über einen on_event-Rückruf verfügen, der ein Ereignis als einzelnes Argument verwendet und entweder nichts oder ein anderes Ereignis oder eine Reihe von Ereignissen zurückgibt. Das Ereignis selbst besteht aus Typ und Wert; Der Typ ist hier nicht im Sinne von str oder int, sondern im Sinne von "Varieté", zum Beispiel "key_down" oder "tick". Die Warteschlange akzeptiert nur Ereignisse bekannter Typen (integriert oder vom Benutzer erstellt) und sendet sie an on_event an alle, die diesen Typ abonnieren. Die Werte werden in keiner Weise überprüft, es gibt jedoch Konventionen innerhalb der Bibliothek darüber, was für jeden Ereignistyp ein gültiger Wert ist.

Zuerst stellen wir ein paar Zuhörer in die Warteschlange. Dies ist die Basisklasse für Objekte, die Ereignisse abonnieren können, jedoch keine Widgets oder Komponenten sind. Im Prinzip ist eine Verwendung nicht erforderlich, solange der Unterzeichner die Methode on_event hatte.

Zuhörer
 #       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') 


Eine vollständige Liste der integrierten Ereignistypen finden Sie in der Dokumentation . Es ist leicht zu erkennen, dass es Ereignisse für die Schaffung und Zerstörung von Entitäten gibt, aber nicht für Schaden. Da wir Objekte haben, die nicht von einem Schuss abfallen (Wände und Panzer des Spielers), werden wir sie erstellen:

Registrierung des Ereignistyps
 #      ,    #   dispatcher.register_event_type('ac_damage') #       logger = LoggingListener(sys.stderr) dispatcher.register_listener(logger, ['ac_damage', 'play_sound']) 


Wir sind uns einig, dass dieses Ereignis als Wert ein Tupel aus der ID des Unternehmens, das den Schaden erlitten hat, und dem Wert des Schadens enthält. LoggingListener ist nur ein Debugging-Tool, das alle empfangenen Ereignisse druckt, wo immer sie sagen, in diesem Fall in stderr. In diesem Fall wollte ich sicherstellen, dass der Schaden korrekt auftritt und dass der Ton immer dann angefordert wird, wenn er sollte.

Mit Listeners können Sie jetzt das erste Widget hinzufügen. Wir haben dieses ECSLayout-Klassenspielfeld. Dies ist ein solches Layout, das Widgets auf Entitäten setzen und diese als Reaktion auf ecs_move-Ereignisse verschieben kann und gleichzeitig Kollisionen berücksichtigt. Wie bei den meisten Widgets sind zwei Argumente erforderlich: eine verschachtelte Liste von Zeichen (möglicherweise leer - Leerzeichen oder Keine) und eine verschachtelte Liste von Farben für jedes Zeichen. Benannte Farben werden als Farben akzeptiert, RGB im Format "0xAARRGGBB" (oder "0xARGB", "0xRGB", "0xRRGGBB") und im Format "#fff". Die Größen beider Listen müssen übereinstimmen. Andernfalls wird eine Ausnahme ausgelöst.

Erstes 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') 


Da wir jetzt wissen, worauf Objekte im Spiel platziert werden sollen, können wir mit dem Erstellen von Entitäten beginnen. Der gesamte Code von Entitäten und Komponenten wird in eine separate Datei verschoben. Die einfachste davon ist eine zerstörbare Mauer. Sie weiß, wie man sich an einem bestimmten Ort befindet, ihr Widget anzeigt, als Kollisionsobjekt dient und Schaden erleidet. Nach genügend Schaden verschwindet die Wand.

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))) 


Zunächst wird das Entitätsobjekt selbst erstellt. Es enthält nur einen Namen (der eindeutig sein muss) und eine Reihe von Komponenten. Sie können entweder bei der Erstellung auf einmal übertragen oder wie hier einzeln hinzugefügt werden. Dann werden alle notwendigen Komponenten erstellt. Als Widget wird SwitchWidget verwendet, das mehrere Zeichnungen derselben Größe enthält und diese per Befehl ändern kann. Beim Erstellen eines Widgets werden übrigens Zeichnungen aus dem Atlas geladen. Und schließlich werden die Ankündigung der Erstellung der Entität und die Reihenfolge, sie auf die erforderlichen Koordinaten zu zeichnen, in die Warteschlange gestellt.

Von den nicht eingebauten Komponenten gibt es nur Gesundheit. Ich habe die Basisklasse "Gesundheitskomponente" erstellt und von ihr das "Widget zum Ändern der Gesundheitskomponente" geerbt (um die Wand intakt und in mehreren Stadien der Zerstörung anzuzeigen).

Klasse 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}) 


Beim Erstellen einer Komponente wird der Schlüssel 'name' an super () .__ init__ übergeben. Wenn eine Komponente zu einer Entität hinzugefügt wird, wird sie unter dem Namen dieses Schlüssels zum __dict__ der Entität hinzugefügt und kann über entity_object.health aufgerufen werden. Neben dem Komfort der Schnittstelle ist dieser Ansatz gut, da er die Ausgabe von Entitäten mehrerer homogener Komponenten verbietet. Die Tatsache, dass es in der Komponente fest codiert ist, ermöglicht es Ihnen nicht, beispielsweise WidgetComponent fälschlicherweise in den Steckplatz der Integritätskomponente einzufügen. Unmittelbar nach der Erstellung abonniert die Komponente die für sie interessanten Ereignisklassen, in diesem Fall ac_damage. Nachdem die Methode on_event ein solches Ereignis erhalten hat, prüft sie eine Stunde lang, ob es sich um ihren Besitzer handelt. In diesem Fall subtrahiert er den gewünschten Wert von den Trefferpunkten und zieht den Rückruf, um den Zustand zu ändern. Die Basisklasse ist abstrakt. Es gibt auch die Methode __repr__, die für die Serialisierung in JSON verwendet wird (z. B. zum Speichern). Es ist nicht erforderlich, es hinzuzufügen, aber alle integrierten Komponenten und die meisten integrierten Widgets haben es.

Durch das Erben der zugrunde liegenden Integritätskomponente von VisualDamageHealthComponent wird der Rückruf bei Integritätsänderungen überschrieben:

Klasse 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]) 


Während der Zustand über 0 liegt, fordert er die für das Widget verantwortliche Komponente auf, die Wand im gewünschten Zustand zu zeichnen. Hier wird der oben beschriebene Aufruf über das Attribut des Entitätsobjekts verwendet. Sobald die Trefferpunkte überschritten sind, werden die Komponente, die für die korrekte Zerstörung der Entität verantwortlich ist, und alle Komponenten auf dieselbe Weise aufgerufen.

Bei anderen Entitäten ist alles ähnlich, nur die Komponenten sind unterschiedlich. Panzer werden mit Controllern (Eingabe für den Spieler, KI für Gegner) und rotierenden Widgets für Muscheln hinzugefügt - eine Kollisionskomponente, die denjenigen Schaden zufügt, die sie treffen. Ich werde nicht jeden von ihnen analysieren, weil es sperrig und eher trivial ist; schau nur auf den Projektilkollider. Es hat eine collided_into-Methode, die aufgerufen wird, wenn die Host-Entität gegen etwas stößt:

Bullet Component Collider
 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() 


Um sicherzustellen, dass es möglich ist, ein Opfer zu finden (was beispielsweise für Hintergrundelemente falsch sein kann), verwendet das Projektil EntityTracker (). Dies ist ein Singleton, der alle erstellten und zerstörten Entitäten verfolgt. Durch sie können Sie ein Entitätsobjekt nach Namen abrufen und etwas mit seinen Komponenten tun. In diesem Fall wird überprüft, ob entity.collision (der Opferkollisionshandler) überhaupt vorhanden ist.

Jetzt rufen wir in der Hauptdatei des Spiels einfach alle notwendigen Funktionen zum Erstellen von Entitäten auf:

Zurück zu 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) 


Punkt- und Trefferpunktzähler sind keine Einheiten und befinden sich nicht auf dem Schlachtfeld. Daher werden sie nicht zu ECSLayout hinzugefügt, sondern direkt zum Terminal rechts von der Karte. Relevante Widgets erben von Label (Textausgabe-Widget) und verfügen über eine on_event-Methode, um herauszufinden, was sie interessiert. Im Gegensatz zu Layout aktualisiert das Terminal Widgets nicht automatisch bei jedem Tick. Nach dem Ändern des Texts weisen ihn die Widgets an, dies zu tun:

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) 


Der feindliche Generator und das Objekt, das für die Ausgabe von „GAME OVER“ verantwortlich ist, werden überhaupt nicht angezeigt und erben daher vom Listener. Das Prinzip ist dasselbe: Objekte hören auf die Warteschlange, warten auf den richtigen Moment und erstellen dann eine Entität oder ein 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) 


Jetzt haben wir alles geschaffen, was wir brauchen, und wir können das Spiel starten.

Wir starten
 #      ,   Listeners    terminal.start() terminal.add_widget(layout) terminal.add_widget(score, pos=(85, 10)) terminal.add_widget(hp, pos=(85, 15)) 


Widgets werden dem Bildschirm erst nach dem Start hinzugefügt. Entitäten können der Karte hinzugefügt werden, bevor - Erstellungsereignisse (in denen die gesamte Entität einschließlich des Widgets gespeichert ist) einfach in der Warteschlange gesammelt und beim ersten Häkchen aufgelöst werden. Das Terminal kann Widgets jedoch erst hinzufügen, nachdem ein Fenster erfolgreich für das Terminal erstellt wurde.

Zu diesem Zeitpunkt haben wir einen funktionierenden Prototyp, den Sie in Early Access für zwanzig Dollar ausgeben können, um Funktionen hinzuzufügen und das Gameplay zu verbessern. Dies geht aber bereits über den Rahmen der heiligen Welt und damit des Artikels hinaus. Ich werde nur hinzufügen, dass der von Systempython unabhängige Build mit pyinstaller erstellt werden kann .

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


All Articles