Gegner, die denken
Kurz gesagt: Ein Gegner auf der Karte ist ein endlicher Automat (schläft, wandert, jagt, flieht), der auf dem kürzesten Weg jagt, den die Breitensuche gefunden hat; im Kampf wählt er die Aktion mit dem höchsten Nutzenwert.
Problem
Der Goblin, der gegen die Wand läuft
Bisher standen die Gegner in der Mine dort, wo die Karte sie hingestellt hat. Wer das sieht, merkt sofort, dass das Spiel aus Pappe ist. Ein lebendiger Gegner muss drei Dinge können: entscheiden, was er tut (schlafen, wandern, jagen, fliehen), seinen Weg finden und im Kampf wählen.
Die einfachste Jagd lautet: „Mach den Schritt, der dich dem Helden am meisten nähert.“ Hier sind ein Goblin (g) und ein Held (@) auf beiden Seiten einer Wand mit einer Lücke unten:
#################......#.......##......#.......##.@....#g......##......#.......##..............#################In gerader Linie sind es 6 Zellen, aber die Wand liegt dazwischen. Die einfache Jagd vergleicht nur die Entfernung: Der Goblin will nach links, dort ist eine Wand, und er bleibt an ihr kleben. Hier bleibt er stehen:
#################......#.......##......#.......##.@....#g......##......#.......##..............#################Eine gerade Linie weiß nichts von Wänden. Wir brauchen einen Weg, der sie umgeht, und der kürzeste Weg auf einem Raster wird mit der Breitensuche gefunden. Die Lektion baut sie zuerst auf der kleinen Ebene 16 × 7 (die Lücke ist unten), setzt dann einen endlichen Automaten darüber, der entscheidet, wann der Gegner sich bewegt, und zeigt zuletzt das zweite Gehirn des Spiels: die Nutzenbewertung im Kampf.
Die Idee
Die Welle: Wie viele Schritte jede Zelle entfernt ist
Stell dir vor, du wirfst einen Stein ins Wasser: Von der Stelle, an der er gefallen ist, breiten sich Kreise aus. Die Breitensuche macht dasselbe über ein Raster. Sie beginnt beim Goblin: Er hat die Entfernung 0. Seine Nachbarn haben 1. Die Nachbarn der Nachbarn (noch nicht gesehen) haben 2. Und so weiter, bis die Welle alles bedeckt. Jede Zelle bekommt eine Zahl: wie viele Schritte sie vom Goblin über den Boden entfernt ist, nicht in gerader Linie.
Man braucht eine Warteschlange: die Zellen, die die Welle erreicht, aber noch nicht angeschaut hat. Vorne wird die älteste Zelle entnommen (deshalb geht die Welle der Reihe nach), und ihre neuen Nachbarn kommen hinten dazu. Eine Zelle, die schon eine Zahl hat, wird kein zweites Mal eingefügt.
MAP = """#################......#.......##......#.......##......#.......##......#.......##..............#################""".strip()
DIRECTIONS = [(0, -1), (1, 0), (0, 1), (-1, 0)]
def distances(level, start_x, start_y): # Die Welle: wie viele Schritte jede Zelle vom Start entfernt ist. dist = [[-1] * level.width for _ in range(level.height)] dist[start_y][start_x] = 0 queue = [Step(start_x, start_y)] head = 0 while head < len(queue): cell = queue[head] head += 1 for dx, dy in DIRECTIONS: next_x = cell.x + dx next_y = cell.y + dy if not level.walkable(next_x, next_y) or dist[next_y][next_x] != -1: continue dist[next_y][next_x] = dist[cell.y][cell.x] + 1 queue.append(Step(next_x, next_y)) return distlevel = Level(MAP)dist = distances(level, ENEMY_X, ENEMY_Y)print("Die Welle vom Feind aus (a = 10, b = 11, ...):")print_wave(level, dist)Die Welle vom Feind aus (a = 10, b = 11, ...):#################dcba98#2345678##cba987#1234567##ba9876#0123456##a98765#1234567##98765432345678#################a ist 10, b ist 11 und so weiter. Der Goblin steht auf 0 (an der Wand, am linken Rand des rechten Raums), und die Wand ist #. Die Welle füllt zuerst den rechten Raum, geht dann durch die Lücke unten und steigt erst danach in den linken. Der Held auf (2, 3) steht auf a, also 10 Schritte vom Goblin entfernt, obwohl es in gerader Linie 6 sind: Die Zahl ist die wirkliche Entfernung über den Boden.
Step ist ein @dataclass(frozen=True) mit x und y. Wir benutzen nicht list.pop(0): Das Entnehmen am Anfang einer Liste verschiebt alle anderen Elemente, deshalb gehen wir stattdessen mit einem Index head voran.Die Prüfung dist[nextY][nextX] != -1 ist das ganze „schon dagewesen“: Die Zelle hat eine Zahl, ist also in der Warteschlange oder war darin. Ohne diese Prüfung würde die Welle zurückfließen und nie enden.
Genauer, für Neugierige
Die vier Richtungen stehen in fester Reihenfolge: oben, rechts, unten, links. Die Reihenfolge ändert die Entfernungen nicht (sie sind bei jeder Reihenfolge gleich), aber sie ändert, welcher von mehreren gleich langen Wegen gefunden wird. Im Spiel ist die Reihenfolge genau deshalb fest: Dieselbe Eingabe muss in Java, C#, Python und JavaScript denselben Weg ergeben, sonst würden die Aufzeichnung eines Kampfes und die Prüfung gegen den aufgezeichneten Durchgang auseinanderlaufen.
Diese Welle von Entfernungen heißt distance field (auch Dijkstra map): Eine Suche vom Helden aus gibt für jede Zelle eine Zahl, und jeder Gegner geht einfach zum Nachbarn mit der kleineren Zahl. Bei vielen Gegnern ist das viel billiger als eine Suche für jeden.
Die Idee
Der Weg: Woher jede Zelle kam
Die Zahlen zeigen die Entfernung, aber der Gegner braucht die Schritte. Wir fügen eine Tabelle hinzu: Für jede Zelle merken wir uns, von welcher Zelle die Welle gekommen ist. Wenn die Welle das Ziel erreicht, gehen wir rückwärts: das Ziel, woher es kam, woher jene kam, und so weiter bis zum Start. So entsteht der Weg von hinten nach vorn, deshalb drehen wir ihn am Ende um.
def find_path(level, start_x, start_y, goal_x, goal_y): came_from = [-1] * (level.width * level.height) start = start_y * level.width + start_x came_from[start] = start queue = [start] head = 0 while head < len(queue): current = queue[head] head += 1 x = current % level.width y = current // level.width if current != start and x == goal_x and y == goal_y: path = [] index = current while index != start: path.append(Step(index % level.width, index // level.width)) index = came_from[index] path.reverse() return path for dx, dy in DIRECTIONS: next_x = x + dx next_y = y + dy if not level.walkable(next_x, next_y): continue next_index = next_y * level.width + next_x if came_from[next_index] != -1: continue came_from[next_index] = current queue.append(next_index) return None
def distance(ax, ay, bx, by): return abs(ax - bx) + abs(ay - by)level = Level(MAP)path = find_path(level, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y)print(f"der Feind ({ENEMY_X}, {ENEMY_Y}), der Held ({HERO_X}, {HERO_Y}): in gerader Linie {distance(ENEMY_X, ENEMY_Y, HERO_X, HERO_Y)}, auf dem Weg {len(path)}")dist = distances(level, ENEMY_X, ENEMY_Y)print("Die Welle vom Feind aus (a = 10, b = 11, ...):")print_wave(level, dist)print("Der Weg zum Helden (*):")print_path(level, path, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y)der Feind (8, 3), der Held (2, 3): in gerader Linie 6, auf dem Weg 10Die Welle vom Feind aus (a = 10, b = 11, ...):#################dcba98#2345678##cba987#1234567##ba9876#0123456##a98765#1234567##98765432345678#################Der Weg zum Helden (*):#################......#.......##......#.......##.@****#g......##.....*#*......##.....***......#################Der Weg (*) geht durch die Lücke nach unten, steigt an der Wand entlang in den linken Raum und läuft nach links zum Helden: 10 Schritte gegenüber 6 in gerader Linie. Die Wand stört den Algorithmus nicht, denn die Welle betritt sie einfach nicht.
Die Suche endet, sobald sie das Ziel aus der Warteschlange nimmt: Danach braucht sie nichts mehr. Deshalb ist das erste gefundene Ziel das nächste. Und wenn es keinen Weg gibt (der Held steht hinter einer Wand ohne Lücke), geht die Warteschlange zu Ende, und die Funktion gibt None zurück.
Genauer, für Neugierige
Der erste Schritt des Weges ist nur eine Zelle, aber der Gegner macht ihn und fragt im nächsten Zug neu. So kann er an einem alten Weg nicht hängen bleiben: Der Held hat sich bewegt, ein anderer Gegner hat die Zelle besetzt, die Tür ist zugefallen.
Die Kosten sind gering. Die Ebene im Spiel ist 50 × 20, etwa 1000 Zellen, und selbst wenn alle besucht werden, sind das ein paar tausend einfache Prüfungen pro Gegner und Zug.
Die Idee
Ein endlicher Automat: schläft, wandert, jagt, flieht
Wann benutzt der Gegner den Weg? Das hängt davon ab, was er gerade ist. Ein Schläfer bewegt sich nicht. Ein Wanderer streift herum. Ein Jäger folgt dem Weg. Ein Fliehender entfernt sich. Das ist ein endlicher Automat: Ein Gegner hat einen Zustand (eine Zeichenkette: "sleep", "wander", "chase", "flee"), und in jedem Zug steht da: „in diesem Zustand, in dieser Lage, was tue ich und in welchen Zustand gehe ich über“.
| Von | Nach | Wann |
|---|---|---|
sleep |
chase |
der Weg zum Helden ist höchstens 5 lang |
wander |
chase |
der Gegner „sieht“ den Helden: Der Weg ist höchstens 8 lang |
chase |
wander |
mehr als 3 Züge ohne Sicht (lost > LOST) |
chase |
flee |
Leben × 4 < maximales Leben (unter einem Viertel) |
Das Programm benutzt dieselbe Karte und einen Gegner mit 16 Leben. „Sehen“ ist vereinfacht: Ein Gegner sieht den Helden, wenn der Weg zu ihm höchstens 8 lang ist. Im Spiel ist das eine Sichtlinie aus der Lektion über die Karte (bis zu 8 Zellen), und hier liefert der Weg dasselbe ohne einen weiteren Algorithmus. Der Ablauf steht in SCRIPT: Der Held nähert sich, duckt sich hinter eine Ecke (hidden), taucht wieder auf und wird zuletzt getroffen (hit).
SIGHT = 8WAKE = 5LOST = 3FAR = 20NONE = 99ENEMY_LIFE = 16
def enemy_turn(level, enemy, hero_x, hero_y, turn, hidden): # Der Feind „sieht“ den Helden, wenn der Weg zu ihm kurz ist (im Spiel: die Sichtlinie aus der Lektion über die Karte). path = find_path(level, enemy.x, enemy.y, hero_x, hero_y) path_length = NONE if path is None else len(path) sees = not hidden and path_length <= SIGHT if enemy.state == "sleep": if sees and path_length <= WAKE: enemy.state = "chase" return if enemy.state == "wander": if sees: enemy.state = "chase" enemy.lost = 0 return # Wandert in der Reihenfolge der Richtungen (das Spiel würfelt). dx, dy = DIRECTIONS[turn % 4] next_x = enemy.x + dx next_y = enemy.y + dy if level.walkable(next_x, next_y) and not (next_x == hero_x and next_y == hero_y): enemy.x = next_x enemy.y = next_y return if enemy.state == "chase": if enemy.life * 4 < enemy.max_life: enemy.state = "flee" return if not sees: enemy.lost += 1 if enemy.lost > LOST: enemy.state = "wander" return else: enemy.lost = 0 if path_length <= 1: return if path_length <= FAR: enemy.x = path[0].x enemy.y = path[0].y return # Flieht: der Schritt, der ihn am weitesten vom Helden entfernt. best_x = enemy.x best_y = enemy.y best_distance = distance(enemy.x, enemy.y, hero_x, hero_y) for dx, dy in DIRECTIONS: next_x = enemy.x + dx next_y = enemy.y + dy if not level.walkable(next_x, next_y) or (next_x == hero_x and next_y == hero_y): continue away = distance(next_x, next_y, hero_x, hero_y) if away > best_distance: best_distance = away best_x = next_x best_y = next_y enemy.x = best_x enemy.y = best_yJeder Zustand ist ein Zweig eines if, der an seinem Ende zurückkehrt: In einem Zug geschieht genau eine Sache. Es liest sich von oben nach unten wie die Tabelle. Der Zähler lost ist das Gedächtnis des Automaten: Ohne ihn würde der Gegner den Helden schon beim ersten Verschwinden vergessen.
level = Level(MAP)enemy = Enemy(ENEMY_X, ENEMY_Y, "sleep", ENEMY_LIFE)for index, (hero_x, hero_y, hit, hidden) in enumerate(SCRIPT): enemy.life = max(0, enemy.life - hit) before = enemy.state enemy_turn(level, enemy, hero_x, hero_y, index + 1, hidden) path = find_path(level, enemy.x, enemy.y, hero_x, hero_y) length = NONE if path is None else len(path) state = before if before == enemy.state else before + " → " + enemy.state print(f"Zug {index + 1}: {state}, Feind ({enemy.x}, {enemy.y}), Weg {length}, Leben {enemy.life}")Zug 1: sleep, Feind (8, 3), Weg 7, Leben 16Zug 2: sleep, Feind (8, 3), Weg 6, Leben 16Zug 3: sleep → chase, Feind (8, 3), Weg 5, Leben 16Zug 4: chase, Feind (8, 4), Weg 4, Leben 16Zug 5: chase, Feind (8, 5), Weg 3, Leben 16Zug 6: chase, Feind (7, 5), Weg 2, Leben 16Zug 7: chase, Feind (6, 5), Weg 1, Leben 16Zug 8: chase → wander, Feind (6, 5), Weg 1, Leben 16Zug 9: wander, Feind (7, 5), Weg 2, Leben 16Zug 10: wander → chase, Feind (7, 5), Weg 2, Leben 16Zug 11: chase, Feind (6, 5), Weg 1, Leben 16Zug 12: chase → flee, Feind (6, 5), Weg 1, Leben 3Zug 13: flee, Feind (7, 5), Weg 2, Leben 3Zug 14: flee, Feind (8, 5), Weg 3, Leben 3Zug 15: flee, Feind (9, 5), Weg 4, Leben 3Zug 3: Der Weg wird 5 lang, und sleep → chase. Züge 4–7: Der Goblin geht den Weg entlang. In Zug 8 übersteigt lost die 3, und er geht zu wander über; in Zug 9 macht er einen Schritt wie ein Wanderer, und in Zug 10 sieht er den Helden wieder. In Zug 12 trifft ihn der Held und nimmt ihm 13 von 16 Leben: 3 × 4 = 12 < 16, und chase → flee. Das Fliehen benutzt den Weg nicht: Von den vier Nachbarn nimmt er den, der ihn am weitesten entfernt (away > bestDistance).
Genauer, für Neugierige
Warum Zeichenketten und kein enum? Der Kanon benutzt Zeichenketten, weil die Zustände in den Spielstand geschrieben und aus einer Datei gelesen werden. So braucht eine neue Gegnerart mit einem neuen Zustand keinen neuen Typ. Der Preis ist, dass ein Tippfehler im Namen ("chaes") beim Kompilieren nicht auffällt.
Ein Automat ist die einfachste Art, Verhalten zu beschreiben, aber er wächst: Zehn Zustände mit je fünf Übergängen sind fünfzig Zeilen, die schwer zu lesen sind. Dann geht man zu einem behavior tree über oder zur Nutzenbewertung, wie sie unten im Kampf vorkommt.
Sieh es dir an
Die Jagd in Aktion
Hier ist dasselbe Zug für Zug. Der Goblin schläft auf der anderen Seite der Wand. Der Held nähert sich; der Weg wird 5, und der Goblin wacht auf. Dann sieht man die Suche: Eine Welle geht vom Goblin aus und läuft durch die Lücke um die Wand herum, bis sie den Helden erreicht. Danach geht der Goblin los, verliert den Helden, bemerkt ihn wieder und flieht schließlich.
def find_path(level, start_x, start_y, goal_x, goal_y): came_from = [-1] * (level.width * level.height) start = start_y * level.width + start_x came_from[start] = start queue = [start] head = 0 while head < len(queue): current = queue[head] head += 1 x = current % level.width y = current // level.width if current != start and x == goal_x and y == goal_y: path = [] index = current while index != start: path.append(Step(index % level.width, index // level.width)) index = came_from[index] path.reverse() return path for dx, dy in DIRECTIONS: next_x = x + dx next_y = y + dy if not level.walkable(next_x, next_y): continue next_index = next_y * level.width + next_x if came_from[next_index] != -1: continue came_from[next_index] = current queue.append(next_index) return None
def enemy_turn(level, enemy, hero_x, hero_y, turn, hidden): # Der Feind „sieht“ den Helden, wenn der Weg zu ihm kurz ist (im Spiel: die Sichtlinie aus der Lektion über die Karte). path = find_path(level, enemy.x, enemy.y, hero_x, hero_y) path_length = NONE if path is None else len(path) sees = not hidden and path_length <= SIGHT if enemy.state == "sleep": if sees and path_length <= WAKE: enemy.state = "chase" return if enemy.state == "wander": if sees: enemy.state = "chase" enemy.lost = 0 return # Wandert in der Reihenfolge der Richtungen (das Spiel würfelt). dx, dy = DIRECTIONS[turn % 4] next_x = enemy.x + dx next_y = enemy.y + dy if level.walkable(next_x, next_y) and not (next_x == hero_x and next_y == hero_y): enemy.x = next_x enemy.y = next_y return if enemy.state == "chase": if enemy.life * 4 < enemy.max_life: enemy.state = "flee" return if not sees: enemy.lost += 1 if enemy.lost > LOST: enemy.state = "wander" return else: enemy.lost = 0 if path_length <= 1: return if path_length <= FAR: enemy.x = path[0].x enemy.y = path[0].y return # Flieht: der Schritt, der ihn am weitesten vom Helden entfernt. best_x = enemy.x best_y = enemy.y best_distance = distance(enemy.x, enemy.y, hero_x, hero_y) for dx, dy in DIRECTIONS: next_x = enemy.x + dx next_y = enemy.y + dy if not level.walkable(next_x, next_y) or (next_x == hero_x and next_y == hero_y): continue away = distance(next_x, next_y, hero_x, hero_y) if away > best_distance: best_distance = away best_x = next_x best_y = next_y enemy.x = best_x enemy.y = best_ySchritt für Schritt, in Worten
- Der Goblin schläft auf (8, 3). Der Held ist auf der anderen Seite der Wand. In Zug 2 ist der Weg zum Helden 6, und zum Aufwachen sind höchstens 5 nötig: Der Goblin schläft weiter, obwohl der Held in gerader Linie nur 4 Zellen entfernt ist.
- Zug 3: Der Held steht auf (6, 4). In gerader Linie, durch die Wand, sind es 3 Zellen, aber der Weg ist 5 ≤ 5: sleep → chase.
- Breitensuche vom Goblin aus: eine Warteschlange, aus der die älteste Zelle zuerst herauskommt. Die Welle breitet sich Zelle für Zelle über den Boden aus und läuft durch die Lücke um die Wand herum. 27 von 66 Zellen kommen aus der Warteschlange, bis der Held erreicht ist.
- Der Held ist gefunden. Vom Helden aus gehen wir über cameFrom Zelle für Zelle zurück zum Goblin und drehen den Weg um: 5 Schritte. Der Goblin macht den ersten.
- In jedem Zug fragt der Goblin neu und macht nur den ersten Schritt des Weges: 4, 3, 2, 1 bis zum Helden. Bewegt sich der Held, ist der Weg ein anderer.
- Der Held duckt sich hinter eine Ecke (die blasse Kugel). Der Goblin geht noch auf ihn zu, zählt aber mit: Nach 3 Zügen ohne Sicht (lost > 3) wird in Zug 8 aus chase → wander.
- Zug 9: Der Goblin wandert. Die Richtung kommt aus der Zugnummer (das Spiel würfelt), für den Helden wirkt der Schritt zufällig, nicht gezielt.
- Zug 10: Der Held taucht wieder auf, der Weg ist 2 ≤ 8: wander → chase. Der Goblin jagt wieder und erreicht den Helden.
- Zug 12: Der Schlag des Helden lässt dem Goblin 3 von 16 Leben. Der Test ist Leben × 4 < maximales Leben: 12 < 16, also chase → flee.
- Fliehen braucht keinen Weg und keine Suche: Von den vier Nachbarn nimmt er den, der ihn am weitesten vom Helden entfernt (2, 3, 4 Zellen).
Der Code neben der Szene ist findPath und enemyTurn aus dem Programm. In jedem Schritt leuchten die Zeilen auf, die gerade arbeiten.
Die Idee
Im Spiel
Was wir gebaut haben, ist dasselbe wie im Kanon, nur kleiner. Hier ist die echte Breitensuche. Sie nimmt zwei Funktionen statt eines fertigen Ziels und einer fertigen Karte des Begehbaren: isPassable sagt, wohin ein Gegner treten darf (nicht durch andere Gegner und nicht durch den Helden), und isGoal sagt, welche Zelle das Ziel ist:
# Breitensuche: eine Warteschlange von Feldern, jedes merkt sich, woher es kam. Das erste gefundene Ziel ist# das nächste. Die Richtungen haben eine feste Reihenfolge (oben, rechts, unten, links), darum ist der Weg gleich# in jeder Sprache.DIRECTIONS = [[0, -1], [1, 0], [0, 1], [-1, 0]]
# is_passable(x, y) und is_goal(x, y) sind Funktionen; liefert den Pfad ohne den Start oder None.def find_path(level, start_x, start_y, is_passable, is_goal): came_from = [-1] * (level.width * level.height) start_index = start_y * level.width + start_x came_from[start_index] = start_index queue = [start_index] head = 0 while head < len(queue): current = queue[head] head += 1 x = current % level.width y = current // level.width if current != start_index and is_goal(x, y): path = [] index = current while index != start_index: path.append(SimpleNamespace(x=index % level.width, y=index // level.width)) index = came_from[index] path.reverse() return path for direction in DIRECTIONS: next_x = x + direction[0] next_y = y + direction[1] if not level.in_bounds(next_x, next_y): continue next_index = next_y * level.width + next_x if came_from[next_index] != -1: continue if not is_passable(next_x, next_y) and not is_goal(next_x, next_y): continue came_from[next_index] = current queue.append(next_index) return None
def distance(ax, ay, bx, by): return abs(ax - bx) + abs(ay - by)Und hier ist der Automat auf der Karte. Außer unseren Zuständen gibt es guard (ein Wächter auf seinem Posten), calm (nachdem der Held aus einem Kampf geflohen ist, wandern die Gegner und bemerken ihn 6 Züge lang nicht) und einen Würfel fürs Aufwachen:
# Die Karten-KI ist eine Zustandsmaschine: schläft → streift umher → jagt → flieht bei wenig Leben.# Der Wächter bleibt auf seinem Posten (guard), bis er den Helden sieht.def enemy_sees_hero(level, enemy, hero): if distance(enemy.x, enemy.y, hero.x, hero.y) > 8: return False return line_of_sight(level, enemy.x, enemy.y, hero.x, hero.y)
def can_enemy_step(game, level, x, y): if not level.is_walkable(x, y): return False if level.enemy_at(x, y) is not None or level.npc_at(x, y) is not None: return False return not (game.hero.x == x and game.hero.y == y)
def move_enemy(game, level, enemy, x, y): if enemy.has_effect('bleed'): level.add_blood(enemy.x, enemy.y) enemy.x = x enemy.y = y
def enemy_turn_on_map(game, level, enemy): hero = game.hero rng = game.rng sees = enemy_sees_hero(level, enemy, hero) hero_sees_it = level.visible[enemy.y][enemy.x] if enemy.calm > 0: enemy.calm -= 1 if enemy.state == 'sleep': if sees and distance(enemy.x, enemy.y, hero.x, hero.y) <= 5 and rng.chance(50): enemy.state = 'chase' if hero_sees_it: game.say(t('ai.wakes', {'name': enemy.name})) return if enemy.state == 'guard': if sees and distance(enemy.x, enemy.y, hero.x, hero.y) <= 6: enemy.state = 'chase' game.say(t('ai.guard', {'name': enemy.name})) return if enemy.state == 'wander': if sees and enemy.calm == 0: enemy.state = 'chase' enemy.lost_turns = 0 if hero_sees_it: game.say(t('ai.notices', {'name': enemy.name})) return roll = rng.next_int(5) if roll < 4: next_x = enemy.x + DIRECTIONS[roll][0] next_y = enemy.y + DIRECTIONS[roll][1] if can_enemy_step(game, level, next_x, next_y): move_enemy(game, level, enemy, next_x, next_y) return if enemy.state == 'chase': if enemy.kind.special != 'warden' and enemy.is_heavily_wounded(): enemy.state = 'flee' if hero_sees_it: game.say(t('ai.flees', {'name': enemy.name})) return if not sees: enemy.lost_turns += 1 if enemy.lost_turns > 5: enemy.state = 'wander' return else: enemy.lost_turns = 0 if distance(enemy.x, enemy.y, hero.x, hero.y) <= 1: return path = find_path(level, enemy.x, enemy.y, lambda x, y: can_enemy_step(game, level, x, y), lambda x, y: x == hero.x and y == hero.y) if path is not None and len(path) > 1 and len(path) <= 20: move_enemy(game, level, enemy, path[0].x, path[0].y) return # flee: der Schritt, der am weitesten vom Helden wegführt best_x = enemy.x best_y = enemy.y best_distance = distance(enemy.x, enemy.y, hero.x, hero.y) for direction in DIRECTIONS: next_x = enemy.x + direction[0] next_y = enemy.y + direction[1] if not can_enemy_step(game, level, next_x, next_y): continue away = distance(next_x, next_y, hero.x, hero.y) if away > best_distance: best_distance = away best_x = next_x best_y = next_y if best_x != enemy.x or best_y != enemy.y: move_enemy(game, level, enemy, best_x, best_y)Die Unterschiede zu unserem Programm sind wenige, aber wichtig:
enemySeesHerobenutzt die Sichtlinie (Bresenham aus der Lektion über die Karte), nicht die Länge eines Weges. Ein Gegner kann dich also durch eine offene Tür sehen, aber nicht durch eine Wand.- Ein Schläfer wacht mit 50 % Wahrscheinlichkeit pro Zug auf (
rng.chance(50)), und ein Wanderer bewegt sich nur bei 4 von 5 Würfen (rng.nextInt(5)). Der Würfel kommt aus dem Generator des Spiels mit Seed, deshalb bleibt das Spiel wiederholbar. lostTurns > 5, nicht 3. Der Weg ist mitpath.length <= 20begrenzt: Ein weiter entfernter Gegner jagt nicht.- Der Boss (
warden) flieht nie, so verwundet er auch ist. - Die Meldungen (
ai.wakes,ai.notices,ai.flees) werden nur geschrieben, wenn der Held den Gegner sieht: Das Spiel verrät nicht mehr, als du siehst.
Die Idee
Im Kampf: Nutzenbewertung
Auf der Karte hat ein Gegner einen Zustand. Im Kampf gibt es nichts zu laufen: Es gibt eine Wahl zwischen mehreren Aktionen, und sie muss vernünftig sein. Ein Automat würde hier eine Zeile für jedes Paar „Lage — Aktion“ bedeuten. Stattdessen bekommt jede Aktion eine Zahl: wie nützlich sie gerade ist. Der Gegner nimmt die größte.
Die Regel ist kurz: Der Standard ist ein Angriff mit 40. Jede andere mögliche Aktion hat ihren eigenen Wert: einen verwundeten Freund heilen 80, spucken 60. Ist ein Wert größer als der beste bisher, ersetzt die Aktion ihn.
class Chooser: def __init__(self, attack): self.best = attack self.best_score = 40 self.trace = attack + " 40"
def consider(self, score, action): self.trace += ", " + action + " " + str(score) if score > self.best_score: self.best = action self.best_score = score
def percent(a, b): return a * 100 // b
def choose_enemy_action(hero, hero_poisoned, allies, heal_wait, spit_wait): chooser = Chooser("Angriff auf " + hero.name) weakest = None for ally in allies: if percent(ally.hp, ally.max_hp) < 50 and (weakest is None or percent(ally.hp, ally.max_hp) < percent(weakest.hp, weakest.max_hp)): weakest = ally if weakest is not None and heal_wait == 0: chooser.consider(80, "heilt " + weakest.name) if spit_wait == 0 and not hero_poisoned: chooser.consider(60, "spuckt auf " + hero.name) return chooser
def weakest_of(enemies): weakest = None for enemy in enemies: if weakest is None or enemy.hp < weakest.hp: weakest = enemy return weakest
def choose_hero_action(hero, has_potion, enemies): chooser = Chooser("Angriff auf " + weakest_of(enemies).name) if percent(hero.hp, hero.max_hp) < 35 and has_potion: chooser.consider(90, "Trank") return chooser
def show(chooser): print(chooser.trace + " → " + chooser.best)Das Programm zeigt sechs Entscheidungen. Jede Zeile ist: die betrachteten Aktionen mit ihren Werten, ein Pfeil und die gewählte.
hero = Fighter("Iva", 30, 40)hurt_hero = Fighter("Iva", 10, 40)wounded_rat = Fighter("Ratte", 3, 9)rat = Fighter("Ratte", 9, 9)goblin = Fighter("Goblin", 16, 16)wounded_goblin = Fighter("Goblin", 6, 16)cultist = Fighter("Kultist", 3, 20)
show(choose_enemy_action(hero, False, [wounded_rat, goblin], 0, 0))show(choose_enemy_action(hero, False, [wounded_rat, goblin], 2, 0))show(choose_enemy_action(hero, True, [rat, goblin], 0, 0))show(choose_enemy_action(hero, False, [wounded_rat, wounded_goblin], 0, 3))show(choose_hero_action(hero, True, [wounded_rat, goblin, cultist]))show(choose_hero_action(hurt_hero, True, [wounded_rat, goblin, cultist]))Angriff auf Iva 40, heilt Ratte 80, spuckt auf Iva 60 → heilt RatteAngriff auf Iva 40, spuckt auf Iva 60 → spuckt auf IvaAngriff auf Iva 40 → Angriff auf IvaAngriff auf Iva 40, heilt Ratte 80 → heilt RatteAngriff auf Ratte 40 → Angriff auf RatteAngriff auf Ratte 40, Trank 90 → TrankDie ersten vier gehören dem Schamanen. Mit einer verwundeten Ratte und bereitem Heilen heilt er (80 > 60 > 40). Muss das Heilen warten (healWait ist 2), spuckt er (60). Ist der Held schon vergiftet und kein Freund verwundet, bleibt der Angriff (40). In der vierten Zeile sind sowohl die Ratte (33 %) als auch der Goblin (37 %) verwundet; der kleinere Prozentsatz wird geheilt, also die Ratte.
Die letzten beiden Zeilen gehören dem Helden auf Autopilot (in der Simulation des Spiels und im automatischen Kampf). Er schlägt den Schwächsten: den mit dem wenigsten Leben. Die Ratte (3) und der Kultist (3) sind gleich, und der erste gewinnt, weil < bei Gleichstand nicht ersetzt. Bei 25 % Leben (unter 35 %) und mit einem Trank übertrifft der Wert des Tranks (90) die 40, und der Held trinkt.
class Chooser:
def __init__(self, attack):
self.best = attack
self.best_score = 40
self.trace = attack + " 40"
def consider(self, score, action):
self.trace += ", " + action + " " + str(score)
if score self.best_score:
self.best = action
self.best_score = scoreEine neue Aktion ersetzt die beste nur, wenn sie besser ist: >. Mit >= würden gleiche Werte die Wahl vertauschen, und die Reihenfolge der Betrachtung würde das Gegenteil entscheiden. Deshalb bleibt der Angriff mit 40 gewählt, bis ihn etwas übertrifft.
Die Zahlen 80, 60, 40 sind kein Gesetz. Sie sind eine Einstellung: Wenn der Schamane zu oft heilt, wird das Spiel langweilig, und wenn zu selten, unmöglich. Deshalb lässt die letzte Lektion (das ganze Spiel und die Balance) tausend Kämpfe laufen, um diese Zahlen einzustellen.
# Im Kampf bewertet die KI jede mögliche Aktion mit einer Zahl (Nutzen) und wählt# die nützlichste: Sie heilt bei wenig Leben, nutzt eine Fähigkeit, wenn sie bereit ist, sonst greift sie an.def choose_enemy_action(battle, enemy): hero = battle.hero best = AttackAction(enemy, hero) best_score = 40
def consider(score, action): nonlocal best, best_score if score > best_score: best = action best_score = score
special = enemy.kind.special if special == 'shaman': weakest = None for ally in battle.alive_enemies(): if percent(ally.hp, ally.max_hp()) < 50 and (weakest is None or percent(ally.hp, ally.max_hp()) < percent(weakest.hp, weakest.max_hp())): weakest = ally if weakest is not None and enemy.cooldown_of('heal') == 0: consider(80, SpecialAction(enemy, 'heal', weakest)) if enemy.cooldown_of('shaman') == 0 and not hero.has_effect('poison'): consider(60, SpecialAction(enemy, 'spit', hero)) elif special == 'firebolt' and enemy.cooldown_of('firebolt') == 0: consider(65, SpecialAction(enemy, 'firebolt', hero)) elif special == 'rend' and enemy.cooldown_of('rend') == 0 and not hero.has_effect('bleed'): consider(55, SpecialAction(enemy, 'rend', hero)) elif special == 'slam' and enemy.cooldown_of('slam') == 0: consider(60, SpecialAction(enemy, 'slam', hero)) elif special == 'warden': if enemy.phase == 1 and enemy.hp * 2 < enemy.max_hp(): consider(100, SpecialAction(enemy, 'summon', hero)) elif enemy.cooldown_of('warden') == 0: consider(60, SpecialAction(enemy, 'slam', hero)) return best
def usable_skills(hero): result = [] for skill_id in hero.skills: skill = SKILLS[skill_id] if hero.mana >= skill.mana and hero.cooldown_of(skill.id) == 0: result.append(skill) return result
def weakest_enemy(battle): weakest = None for enemy in battle.alive_enemies(): if weakest is None or enemy.hp < weakest.hp: weakest = enemy return weakest
# Der Held im Autopilot (die Simulation): dieselbe Nutzenbewertung.def choose_hero_action(battle): hero = battle.hero target = weakest_enemy(battle) best = AttackAction(hero, target) best_score = 40
def consider(score, action): nonlocal best, best_score if score > best_score: best = action best_score = score
life_share = percent(hero.hp, hero.max_hp()) if life_share < 35 and hero.inventory.count('potion_heal') > 0: consider(90, ItemAction(hero, 'potion_heal')) if (hero.has_effect('poison') or hero.has_effect('bleed')) and life_share < 50 and hero.inventory.count('antidote') > 0: consider(70, ItemAction(hero, 'antidote')) many = len(battle.alive_enemies()) >= 2 for skill in usable_skills(hero): score = 0 if skill.kind == 'heal': score = 75 if life_share < 50 else 0 elif skill.kind == 'buff': score = 0 if hero.has_effect(skill.effect) else 50 elif skill.kind == 'debuff': score = 55 if many else 45 elif skill.target == 'all': score = 65 if many else 52 else: score = 55 consider(score, SkillAction(hero, skill, target)) return bestDer Kanon fügt mehr hinzu: den Feuerpfeil des Kultisten 65, Zerreißen 55 (nur wenn der Held noch nicht blutet), einen schweren Schlag 60 und den Boss, der unter halbem Leben Helfer mit dem Wert 100 herbeiruft. Aber die Form ist dieselbe: consider(Wert, Aktion). Die Aktionen sind Objekte aus der Lektion über Fähigkeiten und Aktionen als Befehle: AttackAction, SkillAction und so weiter. Deshalb wählen ein Gegner, ein Held auf Autopilot und eine Simulation auf dieselbe Weise.
Probier es aus
Sage die Ausgabe voraus
Eine kleine Karte mit einem Hindernis. Die Suche beginnt bei (1, 1). Was gibt das Programm aus?
TINY = """########.#...##...#.########""".strip()level = Level(TINY)print_wave(level, distances(level, 1, 1))Die Welle geht über den Boden: von (1, 1) nach unten (1), nach rechts (2 und 3), nach oben (4), nach rechts (5 und 6) und wieder nach unten (7). Die Wand # zwischen 0 und 4 versperrt den direkten Weg, deshalb kommt 4 erst nach 1, 2 und 3. Die zweite Antwort ist die Entfernung in gerader Linie, die nichts von der Wand weiß; die dritte beginnt das Zählen bei 1 statt bei 0.
Probier es aus
Finde den Fehler
Ein Kollege hat die Suche verändert, und jetzt ist der Weg zum Helden länger:
level = Level(MAP)path = find_path(level, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y)print(f"der Feind ({ENEMY_X}, {ENEMY_Y}), der Held ({HERO_X}, {HERO_Y}): in gerader Linie {distance(ENEMY_X, ENEMY_Y, HERO_X, HERO_Y)}, auf dem Weg {len(path)}")print("Der Weg zum Helden (*):")print_path(level, path, ENEMY_X, ENEMY_Y, HERO_X, HERO_Y)der Feind (8, 3), der Held (2, 3): in gerader Linie 6, auf dem Weg 12Der Weg zum Helden (*):#################......#.......##......#.......##*@....#g......##*.....#*......##********......#################Der Weg ist 12 Schritte lang statt 10 und macht einen unnötigen Umweg. Welche Zeile nimmt die Zellen vom falschen Ende?
Wähle die Zeile mit dem Fehler.
Die Zelle wird vom Ende der Liste genommen, nicht vom Anfang. Dadurch ist die Liste ein Stapel und keine Warteschlange: Die neueste Zelle wird zuerst angeschaut, und die Suche folgt einem Gang bis zum Ende (Tiefensuche). Der Weg ist gültig, aber nicht der kürzeste. Richtig: vom Anfang nehmen (mit head), und neue Zellen kommen ans Ende: „first in, first out“.
Genauer
Genauer: A*, Felder und Schwachstellen
A*. Die Breitensuche schaut in alle Richtungen gleich, auch weit vom Ziel entfernt. A* fügt einen Hinweis hinzu: Für jede Zelle berechnet es f = g + h, wobei g die bisherigen Schritte sind und h eine Schätzung, wie viele noch bleiben (meist die Entfernung in gerader Linie entlang der Achsen). Es nimmt die Zelle mit dem kleinsten f, die also am vielversprechendsten aussieht. Man braucht eine Prioritätswarteschlange statt einer gewöhnlichen, und das Ergebnis ist derselbe kürzeste Weg, solange h nie überschätzt. Auf unserer Karte vom Goblin zum Helden nimmt die Breitensuche 59 Zellen aus der Warteschlange, A* nimmt 25. In einem offenen Raum, von (2, 2) nach (6, 2), sind es 19 gegenüber 5. Die Zahlen stammen aus dem Modell der Lektion (A* gibt es hier nicht in allen vier Sprachen). Der Kanon benutzt die Breitensuche: Die Ebenen sind 50 × 20, es gibt keine Bewegungskosten, und A* kostet eine Prioritätswarteschlange und mehr Code in vier Sprachen. Wenn flaches Wasser 2 kostet (die Lektion über die Karte), stimmt die Breitensuche nicht mehr, und man geht zu Dijkstra über; bei großen, offenen Ebenen zu A*.
Ein Feld statt eines Weges. Eine Suche vom Helden aus gibt für jede Zelle eine Zahl (die Welle dieser Karte). Dann geht jeder Gegner zum Nachbarn mit der kleinsten Zahl und sucht nicht selbst. Bei zwanzig Gegnern ist das eine Suche pro Zug statt zwanzig. Viele Spiele mit Horden machen es so. Zum Fliehen geht man andersherum: zur größten Zahl.
Fliehen ist dumm. Unser Flüchtling nimmt den Schritt, der die Entfernung am meisten vergrößert. In der Szene läuft er die untere Reihe entlang, aber in einer Ecke hat er keinen Zug mehr und bleibt stehen: Es gibt keinen Weg, der vorausdenkt. Kluges Fliehen folgt dem Entfernungsfeld (vom Helden aus), in dem das ganze Bild auf einmal sichtbar ist und die Ecke vermieden wird.
Ein Automat wächst mit seinen Übergängen. Jedes neue Verhalten ist ein neuer Zustand und Übergänge hinein und heraus (siehe die Aufgabe: guard und home sind zwei Zustände mit mehreren Übergängen). Die Nutzenbewertung wächst sanfter: Eine neue Aktion ist ein neues consider, ohne Übergänge.
Ein Wert ist eine grobe Zahl. Hier sind die Werte 40, 60, 80, von Hand geschrieben. Bessere Systeme multiplizieren mehrere „Erwägungen“ mit Kurven (wie verwundet er ist, wie weit weg, wie viel Mana er hat), und die Werte ändern sich fließend. Die Regel bleibt dieselbe: Die Zahl entscheidet, und die Entscheidung ist wiederholbar.
Übung
Der Wächter, der zurückgeht
Löse die Aufgabe in deinem Editor. Fang mit dem Programm des Automaten an.
Ein Wächter auf seinem Posten. Ändere den Automaten so, dass ein Gegner an seinem Platz im Zustand guard beginnt. Der Wächter steht und wandert nicht. Ist der Weg zum Helden höchstens 6 lang, geht er zu chase über. Er jagt auf dem Weg; wenn lost über 3 steigt, geht er zu home über und kehrt auf dem kürzesten Weg zu seinem Posten zurück (ein Schritt pro Zug), und wenn er ankommt, ist er wieder guard. Der Wächter hat kein sleep, wander und flee. Der Held schlägt nicht zu: Es gibt kein hit.
Die Karte ist dieselbe, der Wächter steht auf seinem Posten (8, 3), und der Ablauf ist dieser (Held, hit, hidden; der Held ist von Zug 4 bis Zug 12 verborgen):
SCRIPT = [ (3, 5, 0, False), (4, 5, 0, False), (4, 5, 0, False), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True), (4, 5, 0, True),]Die Ausgabe ist:
Zug 1: guard, Feind (8, 3), Weg 7, Leben 16Zug 2: guard → chase, Feind (8, 3), Weg 6, Leben 16Zug 3: chase, Feind (8, 4), Weg 5, Leben 16Zug 4: chase, Feind (8, 5), Weg 4, Leben 16Zug 5: chase, Feind (7, 5), Weg 3, Leben 16Zug 6: chase, Feind (6, 5), Weg 2, Leben 16Zug 7: chase → home, Feind (6, 5), Weg 2, Leben 16Zug 8: home, Feind (7, 5), Weg 3, Leben 16Zug 9: home, Feind (8, 5), Weg 4, Leben 16Zug 10: home, Feind (8, 4), Weg 5, Leben 16Zug 11: home → guard, Feind (8, 3), Weg 6, Leben 16Zug 12: guard, Feind (8, 3), Weg 6, Leben 16Weitere Aufgaben
- Ruhe. Im Kanon wechseln die Gegner nach einer Flucht aus einem Kampf zu
wandermitcalm = 6: Solangecalmüber 0 ist, bemerken sie den Helden nicht. Füge dem Gegner einen Zählercalmhinzu, der in jedem Zug verringert wird. - Um einen anderen Gegner herumgehen. Gib
findPatheine Liste besetzter Zellen (die anderen Gegner) und behandle sie wie Wände. Was passiert, wenn zwei Gegner den Helden in einem engen Gang jagen? - Ein Entfernungsfeld. Löse die Jagd ohne
findPath: Lassdistancesvom Helden aus laufen, und der Gegner geht zum Nachbarn mit der kleinsten Zahl. Wie viele Suchen pro Zug sind das bei fünf Gegnern, und wie viele mitfindPath?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Werfen und eigene Exceptions · Listen: append, remove und sort · dataclass, match und Muster
- Aufgaben zum Thema: Die Chroniken von Arkanum