TL;DR;

Wie kann ich yield-ähnliche Syntax in C++ haben?

Threads statt Skripte

Dieser Artikel beschreibt, wie ich Threads benutze um eine natürliche C++ Syntax für sequenzielle, skriptartige Aktionen zu modellieren.

Meine Terminologie von "Einheit" und "Strategie" ist in KI erklärt.
Kurz: Einheit = Agent. Strategie = Aktuelle Zustand-KI eines Agenten.

Einige Strategien sind im Grunde rein sequenzielle Skripte. "Mache das, danach mache dies, danach mache jenes."

Typischerweise wird für die KI eine Funktion "update" zur Verfügung gestellt, die das Fortschreiten der Strategie aktualisieren soll. Diese Funktion darf dabei nicht blockieren, sondern muss "sofort" zurück kehren.

Eine Sequenz von Aktionen, die über einen längeren Zeitraum geht ist nun aber schwer modellierbar, wenn die Funktion "sofort" beendet werden soll.

Beispiel: Die Strategie "Verwirren" wird ausgeführt, wenn eine Einheit unerwartet blockiert ist. "Verwirren" soll die Einheit etwas drehen, ein paar Sekunden warten, zurück drehen und erneut warten. (Tatsächlich ist mein "Verwirren" in Nightwatch etwas komplexer, aber für dieses Beispiel genügt die einfache Version).

Ideal sollte das also so aussehen:

void update(float elapsed) {
	unit.direction -= 0.3f;
	sleep(1.5f);
	unit.direction += 0.3f;
	sleep(1.5f);
}
Allerdings ist die Implementation des "sleep(1.5f)" dabei nicht direkt möglich. Die Funktion darf nicht blockieren! Es muss also für jede Aktionsfolge vor der Pause, für die Pause selbst und nach der Pause verschiedene Zustände eingeführt werden und je nach Zustand in den entsprechenden Abschnitt verzweigt werden. Die kürzeste (nicht unbedingt leserlichste) Variante, die mir einfällt wäre das:
void update(float elapsed) {
	switch (state_)
	{
	case 0: unit.direction -= 0.3f; state_++; sleeping_ = 1.5f;
	case 1: if (sleeping_ > 0) {sleeping_-=elapsed; break;} else state_++;
	case 2: unit.direction += 0.3f; state_++; sleeping_ = 1.5f;
	case 3: if (sleeping_ > 0) {sleeping_-=elapsed; break;} else state_++;
	}
}
Diese Art der indirekten Steuerung ist fehleranfällig, übermäßig komplex und unnatürlich. Sicher kann das Beispiel noch leserlicher programmiert werden, jedoch bezweifle ich, dass es an die Einfachheit meines Idealbeispiels heran kommt.

Häufig wird daher für sequenzielle Strategien tatsächlich eine Skriptsprache (z.B. Python) verwendet, in welcher man Befehle einzeln ausführen kann. Die aktuelle Position des Interpreters im Skript ersetzt dabei den Zustand "state_" in meinem Beispiel oben und es werden solange Zeilen ausgeführt, bis eine blockierende Anweisung wie "sleep" gelesen wird.

Skriptsprachen haben noch viele andere Vorteile und Nachteile, aber wenn sie nicht eingesetzt werden können, ist nicht alles verloren. Es können auch Threads als Betriebssystem-Mittel benutzen um Agenten mit sequenziellen Strategien zu modelieren. Ich habe diese Idee in der Klasse "Script" implementiert. "Skript" startet beim Anlegen einen neuen Thread, der sofort blockiert bis "Skript::update" aufgerufen wird. Dieses gibt den Thread frei und blockiert selbst, bis entweder ein "sleep" im Skript-Thread aufgerufen wird oder das Skript beendet ist.

DWORD WINAPI ScriptThread( void* param )
{
	Script& script = *(Script*)param;
	script.run();
	script.scriptFinished_ = true;
	SignalObjectAndWait(script.lock_, script.lock_, INFINITE, false);
	return 0;
}

void Script::update(float elapsed)
{
	if (!scriptThread_)
	{
		lock_ = CreateEvent(0, false, false, 0);
		scriptThread_ = CreateThread(0, 0, ScriptThread, this, 0, 0);
		assert(scriptThread_);
		if (!scriptThread_)
		{	// something wrong - break up script
			CloseHandle(lock_);
			finished_ = true;
			return; 
		}
		WaitForSingleObject(lock_, INFINITE);
	}

	if (sleeping_ > 0)
	{
		sleeping_ -= elapsed;
		return;
	}

	if (sleeping_ <= 0)
	{
		sleeping_ = 0;
		SignalObjectAndWait(lock_, lock_, INFINITE, false);
	}

	if (scriptFinished_)
	{
		SignalObjectAndWait(lock_, 0, 0, false);
		CloseHandle(lock_);
		finished_ = true;
	}
}

void Script::sleep(float time)
{
	sleeping_ = time;
	SignalObjectAndWait(lock_, lock_, INFINITE, false);
}
Der Code für "Verwirren" ist nun identisch mit meinem Idealbeispiel oben.

Zu bemerken wäre noch, dass durch diese Benutzung von Threads keine der typischen Multi-Threading-Probleme auftreten können, da der Strategie-Thread nie gleichzeitig mit dem KI-Thread ausgeführt wird.

PS: Selbstverständlich möchte man bei vielen gleichzeitig laufenden Skripten die Größe des Stacks reduzieren, um nicht Speicherplatz zu verschwenden.