Heyho, aufstrebender Pythonista! Du hast jetzt genug Einkaufslisten erstellt, dutzendmal den ollen Zinseszins berechnet und bringst dich beim nächsten “Hello world”-Programm um? Du willst endlich deinem Traum näher kommen und ein eigenes Spiel in Python schreiben? Perfekt! Lassen wir also diesen Unsinn und legen wir los.

Wir beginnen mit einem simplen, RPG-ähnlichen Kampfsystem, das wir in den folgenden Artikeln ausbauen werden. Natürlich kannst du den kompletten Code einfach kopieren und damit anstellen, was du willst, aber es wäre vielleicht cool, wenn du wüsstet, was du da kopierst.
Kein Kampf ohne Kämpfer, hier ist der Bauplan:
|
|
class Fighter(): def __init__(self, name, lvl=1, player_controlled=False): self.name = name self.lvl = lvl self.player_controlled = player_controlled self.hp = attribute(lvl, deviation=2) self.attack = attribute(lvl, 1, 2) self.defense = attribute(lvl, 1, 2) self.agility = attribute(lvl, 1, 4) self.defense_factor = 1.00 self.defeated = False |
Wir können mithilfe des Konstruktors bestimmen, wie der Kämpfer heißt, auf welchem Level er sich befindet und ob entweder der Spieler oder der Computer ihn steuert. Dann setzen wir nicht nicht einfach feste Werte für Hitpoints, Angriffsstärke, Verteidigung und Flinkheit, sondern lassen sie von einer globalen attribute()-Funktion in Abhängigkeit vom Level und des Zufalls bestimmen. Die Funktion sieht so aus:
|
|
def attribute(lvl, factor=4, deviation=None): if deviation: d = random.randint(10 - deviation, 10 + deviation) else: d = 0 return round(factor * (lvl ** (1 + d / 100))) |
Um zu verstehen, was diese Funktion macht, stell dir eine exponentiell steigende Kurve in einem Koordinatensystem wie in der Schule vor und greif dir einen beliebigen Punkt aus der Kurve heraus. Je weiter rechts der Punkt liegt, desto höher ist der Level, und weil die Kurve steigt, spuckt die Funktion auch einen höheren Attributwert aus als bei den weiter links liegenden Punkten. Die Formel für die Kurve ist ziemlich simpel: Der Level dient als Basis, und der Exponent von 1 wird um einen zufälligen Wert zwischen mindestens 0 und höchstens 0.1 erhöht, je nach dem, ob eine Abweichung in beide Richtungen(deviation) angegeben ist. Das Zufallselement ist wichtig, damit die Kämpfer selbst bei gleichem Level noch individuelle Stärken und Schwächen haben. Das Ergebnis wird dann noch mit einem beliebigen Faktor multipliziert.
Ein Kämpfer muss ordentlich zuschlagen können, darum fügen wir der Fighter()-Klasse eine hit()-Methode zu:
|
|
def hit(self, enemy): print(self.name, "holt zum Schlag gegen", enemy.name , "aus ...") damage = ( round(self.attack * random.randint(3, 17) / 10) - round( (enemy.defense * enemy.defense_factor) * (random.randint(0, 5) / 10)) ) if self.beats(enemy, "agility"): enemy.get_hit(damage) else: print(enemy.name, "kann ausweichen!") |
Die kriegt den Gegner als Argument mitgeteilt und berechnet nach einer einfachen Formel den Schaden, den sein Schlag verursachen würde. Genau wie in richtigen Fights fällt nicht jeder Schlag immer genau gleich aus, darum schwankt hier der tatsächliche Angriff zwischen 30% und 170% des eigenen Angriffswertes. Davon ziehen wir zwischen 0% und 50% des aktuellen gegnerischen Verteidigungswertes ab. Dann prüfen wir mit der beats()-Methode, ob der Angreifer in diesem Moment flinker als sein Gegner reagiert, und falls ja, berührt der Schlag den Gegner – falls nicht, konnte der Gegner ausweichen. Die beats()-Methode ist ziemlich clever, weil sie Zufall simuliert und darum auch bei gleichen Attributen verschiedene Ergebnisse liefern kann:
|
|
def beats(self, enemy, attribute): p1, p2 = compare(self, enemy, attribute) diff = p2 - p1 boolean = ( random.randint(1, 100) > (p2 ** (1 + diff / 1000)) / (3 - diff / 100) ) return boolean |
Mithilfe der globalen compare()-Funktion berechnet sie das Verhältnis des eigenen und gegnerischen Attributes in Prozent und dann die Differenz von gegnerischem und eigenem Prozentsatz – das Ergebnis ist entweder negativ oder positiv, aber nie weniger als -100 und nie mehr als 100. Eine Formel, die im Grunde genauso funktioniert wie in attribute(), berechnet schließlich die Chance des Gegners, den Kämpfer in einem Attributvergleich zu schlagen. Je größer eine positive Differenz ist, desto höher ist die Potenz und desto kleiner ist der Teiler, und desto größer ist im Ergebnis die Chance für Kämpfer 2, Kämpfer 1 im Vergleich zu schlagen. Für eine negative Differenz gilt jeweils das Gegenteil.
Die compare()-Funktion ist dagegen extrem simpel:
|
|
def compare(fighter1, fighter2, attribute): a1, a2 = fighter1.__dict__[attribute], fighter2.__dict__[attribute] if a1 == a2: p1, p2 = 100, 100 elif a1 > a2: p1 = 100 p2 = round(100 / (a1 / a2)) else: p1 = round(100 / (a2 / a1)) p2 = 100 return p1, p2 |
Sie gibt einfach nur die Prozentsätze der beiden Kämpfer für ein Attribut zurück, wobei der stärkere mit 100% den Orientierungswert bietet. Diese Funktion wird auch später von der “AI” benutzt, um Entscheidungen zu treffen.
Gut, Kämpfer können nun Schläge austeilen. Aber was passiert, wenn jemand getroffen wird? Das erledigt die get_hit()-Methode der Fighter()-Klasse:
|
|
def get_hit(self, damage): if damage <= 0: print(self.name, "kann den Schlag abwehren!") else: print(self.name, "wird getroffen und erleidet", damage, "Punkte Schaden.") self.hp -= damage if self.hp <= 0: self.get_defeated() |
Die hit()-Methode des Angreifers teilt dem Verteidiger den berechneten Schaden mit. Wenn der kleiner oder gleich Null ist, kann der Verteidiger diesen Schlag abwehren, ansonsten wird ihm der Schaden von den Hitpoints abgezogen. Wenn ihm dadurch die Hitpoints ausgehen, markieren wir den Kämpfer als besiegt. Das Spiel reagiert später darauf. Die get_defeated()-Methode ist ausgelagert, weil sie in Zukunft noch mehr erledigen könnte. Im Moment ist sie ganz spartanisch:
|
|
def get_defeated(self): self.defeated = True print(self.name, "ist besiegt.") |
Unter Umständen zieht es ein Kämpfer vor, in einer Runde auf einen Angriff zu verzichten und stattdessen seine Chancen zu verbessern, den nächsten Schlag des Gegners abzuwehren. Dafür beherrscht er die defend()-Methode, die einen zufälligen Faktor zwischen 1.1 und 1.3 bestimmt, mit dem sein Verteidigungswert beim nächsten gegnerischen Angriff multipliziert wird.
|
|
def defend(self): print(self.name, "nimmt eine Verteidigunshaltung ein.") self.defense_factor = random.randint(11, 13) / 10 |
Oder er könnte versuchen, dem Kampf zu entfliehen, hier die escape()-Methode:
|
|
def escape(self, enemy): if self.beats(enemy, "agility"): print(self.name, "gelingt die Flucht!") return True else: print(self.name, "kann", enemy.name, "nicht entfliehen!") return False |
Hier benutzen wir wieder die beats()-Methode, diesmal um den Fluchtversuch zu simulieren, und wieder ist die Flinkheit das entscheidende Attribut. Der Rückgabewert steht für den Erfolg oder Misserfolg der Flucht und wird in der Spielschleife aufgegriffen, die darauf angemessen reagiert.
Wo wir von der Spielschleife sprechen …! Jetzt haben wir unsere Kämpfer, doch wie lassen wir sie gegeneinander antreten? Weil das Programm noch nicht besonders komplex ist, können wir die Logik komfortabel in der main()-Funktion des Programms ausbreiten:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
def main(): blue_fighter = Fighter("Blau", 5, True) red_fighter = Fighter("Rot", 4) if blue_fighter.beats(red_fighter, "agility"): attacker = blue_fighter defender = red_fighter else: attacker = red_fighter defender = blue_fighter turn_ = 1 while True: print_stats(blue_fighter, red_fighter, turn_) if fight(attacker, defender): attacker.turn_update() defender.turn_update() attacker, defender = defender, attacker turn_ += 1 else: break |
Erst einmal erstellen wir natürlich zwei Kämpfer, und blue_fighter soll vom Spieler gesteuert werden. Dann bedienen wir uns wieder einmal der beats()-Methode, um zu erfahren, wer das Flinkheits-Duell gewinnt und als Erster angreifen darf. Die while-Schleife ruft nun so lange die globale fight()-Funktion auf, bis der Kampf auf irgendeine Art beendet wird (Sieg bzw. Niederlage, Flucht, Spielende). Die turn_update()-Methode ist dafür gedacht, etwaige temporäre Zustände wieder zu entfernen und Standardwerte wiederherzustellen, aber bisher setzt sie nur den temporären Verteidigungsbonus von defend() wieder zurück:
|
|
def turn_update(self): self.defense_factor = 1.00 |
Die globale print_stats()-Funktion zeigt dem Spieler vor jedem Zug eines Kämpfers die Hitpoints der Kämpfer an, damit er eine begründete Entscheidung treffen kann:
|
|
def print_stats(fighter1, fighter2, turn_): print("=== RUNDE {}: {} (LVL {}): {} | {} (LVL {}): {} ===".format( (turn_ + 1) // 2, fighter1.name, fighter1.lvl, fighter1.hp, fighter2.name, fighter2.lvl, fighter2.hp)) |
Kommen wir nun zur fight()-Funktion, in der die wesentlichen Entscheidungen der Kämpfer getroffen und bearbeitet werden:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
def fight(attacker, defender): if attacker.player_controlled: choice = fight_options(attacker) while not choice in ("a", "v", "f", "q"): print("Ungültige Auswahl.") choice = fight_options(attacker) if choice == "a": attacker.hit(defender) return not defender.defeated elif choice == "v": attacker.defend() return True elif choice == "f": return not attacker.escape(defender) elif choice == "q": print("Spiel beendet.") return False else: return attacker.decide(defender) |
Hier teilt sich der Weg: in einen für vom Spieler kontrollierte und einen für vom Computer gesteuerte Kämpfer. Erstere erhalten mit der fight_options()-Funktion ein Menü angeboten, in dem sie ihren Zug bestimmen, und je nach Auswahl wird die entsprechende Fighter()-Methode ausgeführt. Wenn daraufhin der Kampf weiterläuft, gibt die Funktion True zurück, doch falls der Kampf durch irgendeine Auswahl oder einen besiegten Kämpfer beendet wird, gibt sie False zurück, worauf main() mit dem Abbruch der Spielschleife, break, reagiert (siehe oben).
Sehen wir uns kurz die Kampfoptionen von fight_options() an:
|
|
def fight_options(fighter): return input("Spieler {}, wähle eine Option und bestätige mit Enter:\n " "[a] Angreifen\n [v] Verteidigen\n " "[f] Flüchten\n [q] Spiel verlassen\n".format(fighter.name)) |
Der Spieler trifft seine Entscheidung also über ein Eingabefeld in der Konsole, und der entsprechende String wird fight() mitgeteilt.
Wesentlich interessanter ist die decide()-Methode der Fighter()-Klasse, die von fight() aufgerufen wird (siehe oben). Sie ist unsere “AI”, unsere künstliche Intelligenz für die vom Computer gesteuerten Kämpfer, obwohl sie in diesem Fall sehr einfach gestrickt ist:
|
|
def decide(self, enemy): ap = compare(self, enemy, "attack")[0] dp = compare(self, enemy, "defense")[0] hpp = compare(self, enemy, "hp")[0] if (ap < 20 and dp < 20) or (hpp < 20 and ap < 50 and dp < 50): return not self.escape(enemy) elif ap < 20: self.defend() else: self.hit(enemy) return not enemy.defeated return True |
Zunächst holt sich die “AI” die Prozentsätze seines eigenen Kämpfers für Angriffsstärke, Verteidigung und verbliebene Hitpoints im Vergleich zu denen seines Gegners. Wenn der computergesteuerte Kämpfer schwächer abschneidet, wird ein Prozentsatz kleiner als 100 sein. Das ist noch kein Grund für Gefahr. Aber falls sowohl seine eigene Angriffsstärke als auch seine Verteidigung weniger als 20% der gegnerischen Werte ausmachen oder falls sie weniger als 50% ausmachen und dafür die Hitpoints weniger als 20% der gegnerischen Hitpoints entsprechen, sieht der Computer einen gewichtigen Grund, die Flucht anzutreten, statt sich auf einen aussichtslosen Kampf einzulassen. Wenn lediglich die Angriffsstärke nur 20% des gegnerischen Wertes ausmacht, probiert er es mit Verteidigung, um einen Schlag abzuwehren. In allen anderen Fällen greift der Computer seinen Gegner an. Die decide()-Methode gibt außerdem den Wert True zurück, falls der Kampf weitergeht, und False, falls der Kampf durch Sieg bzw. Niederlage oder eine erfolgreiche Flucht beendet wird.
Weil wir ein paar Mal die in der Python-Standardbibliothek enthaltene round()-Methode benutzen, um Dezimalzahlen zu runden, diese Methode aber seit Python 3 nicht mehr dem Verfahren entspricht, wie man es aus der Schule kennt und erwartet (“Runde immer ab .5 auf!”), sondern bei geraden Vorkommastellen vor .5 ab- und bei geraden aufrundet, stellen wir das alte und erwartete Verhalten wieder her, indem wir in unserem Programm round() mit einer globalen Funktion überschreiben:
|
|
def round(x): return int(decimal.Decimal(str(x)).quantize( decimal.Decimal('1'), rounding=decimal.ROUND_HALF_UP)) |
Damit ist die Konsolen-Version unserer RPG-artigen, rundenbasierten Kampfengine so gut wie fertig. Fehlen nur noch die Importe und ein paar Konventionen:
|
|
#!/usr/bin/env python3 import decimal import random # Unser gesamter restlicher Code kommt hier hin! if __name__ == "__main__": main() |
Die komplette Fassung als Python-Skript: simpleFight
Nachdem wir jetzt verstanden haben, wie die Kämpfer interagieren und die Züge sich abwechseln, reisen wir in der nächsten Ausgabe in die 90er Jahre und spielen mit grafischer Oberfläche, Maus und diesen ganzen neumodischen Interfaces, die sich niemals durchsetzen werden! Und weil ich keine sadomasochistische Ader habe, setze ich auf Qt und PyQt.