Dies ist eine Beschreibung meiner AI Implementierung "Strategy-Listen" in Nightwatch und ein kurzes Resumé, wie und ob es sich lohnte.

Bemerkung vorweg: Dieser Artikel entstand 2010 zum Spiel "Nightwatch". Mittlerweile habe ich etliche KI's mit verschiedenen Systemen für meherer große Spiele geschrieben. Meine gewonnen Erfahrungen daraus sind unten zusammengefasst.

KI - Design in Nightwatch

KI ist häufig eine State-Machine. Jeder Agent verfügt über einen (oder mehrere) Zustände und jedes Frame agiert er entsprechend dieser Zustände. Ein Wechsel erfolgt entweder über ein Eventsystem (push) oder der Agent fragt in seiner Zeitscheibe gezielt nach benötigten Informationen (poll).

Ich hatte in der Vergangenheit schlechte Erfahrung mit großen State-Machines gemacht. Die Anzahl verschiedener Zustände wächst sehr schnell an. Implizite Zustände sind verdeckte Querabhängigkeiten zwischen verschiedenen Agenten und steigern die Komplexität enorm. Im schlimmsten Fall verdoppelt sich die Anzahl logischer Zustände aller anderen Agenten, wenn ein neuer Zustand in einem Agent hinzukommt.

Polling kostet häufig immense Performance. Strategien um die Performance zu verbessern sind meist kompliziert und schwerfällig (komplexere Datenstrukturen, Caches).

Eventsteuerung hingegen ist meist schwerer zu lesen und zu verstehen. Die Ablauflogik ist schlechter nachzuvollziehen und wirkt unnatürlich. Viele Events oder deren Variablen müssen zwischengespeichert werden. Wird ein zwischenspeichern vermieden, führt dies häufig zu stottern (bursts), wenn Event Handlern zu langsam sind. Die Bearbeitungslogik ist zeitlich und räumlich verteilt was die Probleme versteckter Abhängigkeiten vergrößert.

Das Problem der Ablauflogik

Was meine ich mit "unnatürlicher Ablauflogik" bei eventbasierten Systemen? Ich möchte dies kurz an folgendem Beispiel demonstrieren: Nehmen wir an, eine Einheit soll zu einem Gegner laufen, ihn angreifen und wieder zum Ursprungspunkt zurückkehren. Mit "polling" würde das so aussehen:
// Pseudocode, not from Nightwatch
void Unit::update()
{
	if (!unit_.touches(enemy_)) // collision polling.
	{
		moveToward(enemy_); // moves one step
		return;
	}
	if (enemy_.isAlive())
	{
		attackEnemy(enemy_); // attack one time
		return;
	}
	moveToward(origPosition_);
}
Zum Problem, wenn der Gegner durch andere Einflüsse stirbt, siehe unten.

Die Funktion ist relativ einfach aufgebaut und leicht nachzuvollziehen. Zu beachten ist hier natürlich, dass "update" nicht blockieren darf, sondern immer nur ein kurzes Update der Einheit für ein Tick darstellt. Dieses Problem ist aber unabhängig von "Events vs. Polls" und wird in "Skripte" näher behandelt.

Mit Eventsteuerung wäre eine typische Implementation:

// Pseudocode, not from Nightwatch
void Unit::init()
{
	CollisionManager::touchingEvent().connect(&onTouching);
	enemy_.destroyedEvent().connect(&onVictory);
}
void Unit::onTouching(const Enemy& e) {if (e == enemy_) state_ = ATTACK;}
void Unit::onVictory(const Unit& u) {state_ = MOVE_BACK;}
void Unit::update()
{
	switch (state_)
	{
	case MOVE: moveToward(enemy_); break;
	case ATTACK: attackEnemy(enemy_); break;
	case MOVE_BACK: moveToward(origPosition_); break;
	};
}
Für Programmierer, die mit Eventsystemen vertraut sind, mag das nicht sonderlich komplex aussehen. Aber es hat definitiv ein höheres Potenzial unübersichtlich zu werden.

Polling oder Events?

Es ist unwahrscheinlich, dass ich in einem so überschaubaren Projekt auf Performanceprobleme durch Polling stoße. Durch meine Arbeit mit Java/Swing fühle ich mich vertraut mit Eventsteuerungen, daher wollte ich für Nightwatch die Vor- und Nachteile der Polling-Methode genauer betrachten. Ich wollte sehen ob es die Mühe wert oder vielleicht sogar schädlich ist.

Die Hoffnungen waren dabei vor allem:

  1. Ich kann komplexe Eventsteuerung vermeiden. Die Ablauflogik wirkt nicht übermäßig unnatürlich.
  2. Wenn komplexe Zustände in Teilstrategien zerlegt werden, wird die Anzahl der Querbezüge minimiert.

Strategielisten

Als erstes behandelte ich Zustände als Objekte; im folgenden "Strategien" genannt. Statt nur einer aktuellen Strategie, gab ich jedem Agenten (Einheiten) einen Stack von Strategien. In jedem Tick wird stets die oberste Strategie ausgeführt. Beispiele für Strategien sind "Füge Y Schaden zu" oder "Fliehe vor Y".

Eine beendete Strategie wird vom Stack entfernt und im nächsten Tick wird die vorherige Strategie ausgeführt. Strategien können so sehr einfach Unterstrategien "aufrufen", indem sie diese auf den Stack schreiben.

Komplexere Aktionen wurden durch Kombination von überschaubaren Strategien modeliert. Ein abgeschossener Pfeil erhält die Strategien "Bewegen" und "Selbstmord". Anderes Beispiel: Die "Vorwärts"-Strategie der Zombies sieht nach Menschen im Angriffsradius. Sind welche vorhanden, schreibt sie "Verfolgen" auf den Stack. Ansonsten bewegt sich der Zombie in Richtung Stadt. Stößt er auf ein Hindernis, wird es angegriffen ("Plündern"). Sind die Substrategien wie "Verfolgen" beendet, wird automatisch wieder "Vorwärts" im nächsten Tick ausgeführt.

Resumé

Bis jetzt haben sich die Hoffnungen begrenzt erfüllt.

Positiv ist der einfache Wechsel auf Unterstrategien. Durch die Stack-Modelierung wird der mögliche Zustandsübergang von Einheiten start vereinfacht. Viele Sachverhalte waren so einfach abbildbar. Die Logik komplexerer Strategien wie das oben beschriebene "Vorwärts" ist erstaunlich simpel und einfach zu lesen.

void Forward::update(float elapsed)
{
	auto p = nearestEnemy(unit.pos, unit.party);
	if (p.enemy && p.distance < unit.sense
		&& lineOfSight(unit.pos, p.enemy->pos))
	{
		// I see someone. CHARGE!
		unit.addStrategy(new Chase(unit, *p.enemy, true, true));
		return;
	}

	// steer towards the target destination, if completely out of order
	float alpha = Vector::normalize(unit.direction)-direction;
	if (abs(alpha) > 0.25)
	{
		unit.direction -= alpha * elapsed;
		return;
	}

	Vector d = Vector::polar(unit.direction, unit.speed) * elapsed;

	// sometimes, change direction a bit (dizzy walking)
	if (randf() < elapsed*unit.speed)
	{
		if (d.cross(target) > 0)
			d = d.rotate(randf()/4-0.1f); // rotate clockwise
		else
			d = d.rotate(-(randf()/4-0.1f)); // rotate counter-clockwise
	}

	if (!unit.tryMove(d))
		unit.addStrategy(new Pillage(unit, unit.pos + d));
}
Tatsächlich macht dieses "Vorwärts" noch mehr: es implementiert ein "schlendern" der Einheit, was den "BRAAAIIINSS" - Character der Zombies hervorhebt. Außerdem dreht es die Einheit langsam, wenn sie gerade in eine völlig andere Richtung blickt. In Nightwatch haben Einheiten keine Trägheit und dieser Code soll das für Zombies etwas kompensieren.

Interessant ist auch, dass hier die Substrategien "Charge" und "Pillage" nichts von "Forward" wissen müssen. "Charge" wird beispielsweise auch von Soldaten der Menschen benutzt.

Für das Zerstören von Resourcen erwies sich Eventsteuerung als das simplere Konzept. Wenn z.B. Einheiten sterben, betrifft dies auch Strategien "weiter unten" im Stack von anderen Einheiten. Wird die Einheit gelöscht, werden Referenzen ungültig.

Um das nicht-vorhanden sein von Resourcen durch Polling testen zu können müsste man entweder eine weitere Indirektion einführen (z.B. std::tr1::weak_ptr) oder die Resource wird künstlich am Leben erhalten; die Resource bekommt ein "isDestroyed" flag und wird erst wirklich gelöscht, wenn sie nicht mehr referenziert wird. Die Resource gehört sich damit selber. (Der letzte Ansatz ist sehr typisch in Sprachen wie Java.)

In Nightwatch ist die Existenz von Resourcen streng an ihre Besitzer gebunden. Daher entschloss ich mich, das zerstören von Resourcen über Events zu signalisieren.

Die Modellierung von Abhängigkeiten zwischen den Strategien wird durch den Stack erschwert. Beispiel: Die Arbeitereinheit erhält die Strategien "Gehe zu X" und "Nehme Bonus Y". Wenn der Bonus verschwindet während sich die Einheit noch bewegt, reagiert darauf zwar die Strategie "Nehme Bonus Y" und kann sich beenden, aber sie müsste nun ebenso die "Bewegen"-Strategie beenden. Eine neue Abhängigkeit muss modeliert werden. ("Bewegen" könnte auf das Zerstören des Bonus oder das der "Nehme"-Strategie hören, aber wieso sollte "Bewegen" etwas von einem "Bonus" wissen? Es wird auch in anderen Kontexten benutzt!)

Neue Erkentnisse

Seit Nightwatch und dem obigen Text sind nun viele Jahre vergangen und ich habe so manches KI System implementiert und wieder re-implementiert. In großen Spielen setze ich nun fast nur noch auf ein gut getuntes Event-System. Gute Namensgebung, eine leicht lesbare Registrierungssyntax und automatische De-registrierung bei zerstörten Event-Sender oder Empfängern hilft meist, der Komplexität entgegen zu wirken.

Häufig sollen auch Game-Designer die Möglichkeit haben, KI scripte zu entwerfen. In diesem Fall ist ohnehin eine visualisierte Script-Sprache (z.B. über Behaviour trees) oder eine sehr domainspezifische Sprache angebracht.