Sie können Ihr System , als ob es zusammengesetzt war aus einer Reihe von Staaten und Funktionen, in denen eine Funktion anzuzeigen f[j]
mit Eingabe x[j]
ändert den Systemzustand s[j]
in dem Zustand s[j+1]
, etwa so:
s[j+1] = f[j](s[j], x[j])
Ein Staat ist die Erklärung Ihrer gesamten Welt. Die Standorte des Spielers, der Standort des Feindes, die Punktzahl, die verbleibende Munition usw. Alles, was Sie benötigen, um einen Rahmen Ihres Spiels zu zeichnen.
Eine Funktion ist alles, was die Welt beeinflussen kann. Ein Frame-Wechsel, ein Tastendruck, ein Netzwerkpaket.
Die Eingabe sind die Daten, die die Funktion annimmt. Ein Frame-Wechsel kann die Zeit in Anspruch nehmen, die seit dem letzten Frame vergangen ist. Der Tastendruck kann die tatsächlich gedrückte Taste sowie die Frage umfassen, ob die Umschalttaste gedrückt wurde oder nicht.
Aus Gründen dieser Erklärung werde ich die folgenden Annahmen treffen:
Annahme 1:
Die Anzahl der Zustände für einen bestimmten Lauf des Spiels ist viel größer als die Anzahl der Funktionen. Sie haben wahrscheinlich Hunderttausende von Zuständen, aber nur einige Dutzend Funktionen (Rahmenwechsel, Tastendruck, Netzwerkpaket usw.). Natürlich muss die Anzahl der Eingaben gleich der Anzahl der Zustände minus eins sein.
Annahme 2:
Die räumlichen Kosten (Speicher, Platte) zum Speichern eines einzelnen Zustands sind viel größer als die zum Speichern einer Funktion und ihrer Eingabe.
Annahme 3:
Die zeitlichen Kosten (Zeit) für die Darstellung eines Zustands sind ähnlich oder nur ein oder zwei Größenordnungen länger als die für die Berechnung einer Funktion über einem Zustand.
Abhängig von den Anforderungen Ihres Wiedergabesystems gibt es verschiedene Möglichkeiten, ein Wiedergabesystem zu implementieren, sodass wir mit dem einfachsten beginnen können. Ich werde auch ein kleines Beispiel mit dem Schachspiel machen, das auf Zetteln aufgezeichnet ist.
Methode 1:
Speichern s[0]...s[n]
. Das ist sehr einfach, sehr unkompliziert. Aufgrund der Annahme 2 sind die räumlichen Kosten dafür ziemlich hoch.
Für Schach würde dies durch Ziehen des gesamten Brettes für jeden Zug erreicht.
Methode 2:
Wenn Sie nur eine Vorwärtswiedergabe benötigen, können Sie einfach speichern s[0]
und dann speichern f[0]...f[n-1]
(denken Sie daran, dies ist nur der Name der ID der Funktion) und x[0]...x[n-1]
(was war die Eingabe für jede dieser Funktionen). Zum Wiederholen beginnen Sie einfach mit s[0]
und berechnen
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
und so weiter...
Ich möchte hier eine kleine Anmerkung machen. Mehrere andere Kommentatoren sagten, dass das Spiel "deterministisch sein muss". Jeder, der dies sagt, muss Computer Science 101 erneut absolvieren, denn ALLE COMPUTERPROGRAMME SIND DETERMINISTISCH¹, es sei denn, Ihr Spiel soll auf Quantencomputern ausgeführt werden. Das ist es, was Computer so großartig macht.
Da Ihr Programm jedoch höchstwahrscheinlich von externen Programmen abhängt, die von Bibliotheken bis zur tatsächlichen Implementierung der CPU reichen, kann es schwierig sein, sicherzustellen, dass sich Ihre Funktionen auf verschiedenen Plattformen gleich verhalten.
Wenn Sie Pseudozufallszahlen verwenden, können Sie entweder die generierten Zahlen als Teil Ihrer Eingabe x
oder den Status der PRNG-Funktion als Teil Ihres Status s
und die Implementierung als Teil der Funktion speichern f
.
Für Schach würde dies erreicht werden, indem das ursprüngliche Brett (das bekannt ist) gezeichnet und dann jeder Zug beschrieben wird, wobei angegeben wird, welche Figur wohin gegangen ist. So machen sie es übrigens auch.
Methode 3:
Jetzt möchten Sie höchstwahrscheinlich in der Lage sein, in Ihre Wiederholung zu suchen. Das heißt, rechnen Sie s[n]
für eine beliebige n
. Wenn Sie Methode 2 verwenden, müssen Sie erst rechnen, s[0]...s[n-1]
bevor Sie rechnen können s[n]
, was nach Annahme 2 recht langsam sein kann.
Um dies zu implementieren, ist Methode 3 eine Verallgemeinerung der Methoden 1 und 2: Speichern f[0]...f[n-1]
und x[0]...x[n-1]
genau wie Methode 2, aber auch Speichern s[j]
für alle j % Q == 0
für eine gegebene Konstante Q
. Einfacher ausgedrückt bedeutet dies, dass Sie ein Lesezeichen in jedem Q
Bundesstaat speichern . Zum Beispiel für Q == 100
Sie speicherns[0], s[100], s[200]...
Um s[n]
für eine beliebige zu berechnen n
, laden Sie zuerst die zuvor gespeicherten s[floor(n/Q)]
und berechnen dann alle Funktionen von floor(n/Q)
bis n
. Sie berechnen höchstens Q
Funktionen. Kleinere Werte von Q
sind schneller zu berechnen, belegen jedoch viel mehr Speicherplatz, während größere Werte von Q
weniger Speicherplatz belegen, die Berechnung jedoch länger dauert.
Methode 3 mit Q==1
ist dasselbe wie Methode 1, während Methode 3 mit Q==inf
dasselbe wie Methode 2 ist.
Für Schach wird dies durch Ziehen jedes Zuges sowie eines von 10 Brettern (für Q==10
) erreicht.
Methode 4:
Wenn Sie umkehren Wiederholung möchten, können Sie eine kleine Variation des Verfahrens machen 3. Nehmen wir an Q==100
, und Sie möchten berechnen , s[150]
durch s[90]
in umgekehrter Richtung. Mit der unveränderten Methode 3 müssen Sie 50 Berechnungen durchführen, um zu erhalten, s[150]
und dann 49 weitere Berechnungen, um zu erhalten s[149]
und so weiter. Aber da Sie bereits berechnet haben s[149]
, um zu erhalten s[150]
, können Sie einen Cache mit erstellen, s[100]...s[150]
wenn Sie s[150]
zum ersten Mal berechnen , und Sie sind dann bereits s[149]
im Cache, wenn Sie ihn anzeigen müssen.
Sie müssen nur den Cache jedes Mal , wenn Sie berechnen müssen regenerieren s[j]
, für j==(k*Q)-1
gegeben für jeden k
. Diesmal führt das Erhöhen Q
zu einer kleineren Größe (nur für den Cache), aber zu längeren Zeiten (nur zum Neuerstellen des Caches). Ein optimaler Wert für Q
kann berechnet werden, wenn Sie die zur Berechnung von Zuständen und Funktionen erforderlichen Größen und Zeiten kennen.
Für das Schachspiel würde dies durch das Zeichnen jeder Bewegung sowie einer von 10 Tafeln (für Q==10
) erreicht, aber es würde auch erforderlich sein, die letzten 10 Tafeln, die Sie berechnet haben, in einem separaten Blatt Papier zu zeichnen.
Methode 5:
Wenn Zustände einfach zu viel Platz verbrauchen oder Funktionen zu viel Zeit verbrauchen, können Sie eine Lösung erstellen, die die umgekehrte Wiedergabe tatsächlich implementiert (keine Fälschungen). Dazu müssen Sie für jede Ihrer Funktionen Reverse-Funktionen erstellen. Dies setzt jedoch voraus, dass jede Ihrer Funktionen eine Injektion ist. Wenn dies machbar ist, ist f'
das f
Berechnen zur Bezeichnung der Umkehrung der Funktion s[j-1]
so einfach wie
s[j-1] = f'[j-1](s[j], x[j-1])
Beachten Sie, dass hier sind die Funktion und die Eingabe beide j-1
nicht j
. Dieselbe Funktion und Eingabe wären die, die Sie verwendet hätten, wenn Sie gerechnet hätten
s[j] = f[j-1](s[j-1], x[j-1])
Das Inverse dieser Funktionen zu erzeugen, ist der schwierige Teil. Dies ist jedoch normalerweise nicht möglich, da einige Statusdaten normalerweise nach jeder Funktion in einem Spiel verloren gehen.
Diese Methode kann, wie sie ist, die Berechnung rückgängig machen s[j-1]
, jedoch nur, wenn Sie dies getan haben s[j]
. Dies bedeutet, dass Sie die Wiedergabe nur rückwärts sehen können, beginnend an dem Punkt, an dem Sie beschlossen haben, sie rückwärts abzuspielen. Wenn Sie von einem beliebigen Punkt aus rückwärts abspielen möchten, müssen Sie dies mit Methode 4 mischen.
Für Schach kann dies nicht implementiert werden, da Sie mit einem bestimmten Brett und dem vorherigen Zug wissen können, welche Figur bewegt wurde, aber nicht, woher sie gezogen wurde.
Methode 6:
Wenn Sie nicht garantieren können, dass alle Ihre Funktionen Injektionen sind, können Sie einen kleinen Trick machen, um dies zu tun. Anstatt zu veranlassen, dass jede Funktion nur einen neuen Status zurückgibt, können Sie auch die verworfenen Daten zurückgeben:
s[j+1], r[j] = f[j](s[j], x[j])
Wo r[j]
sind die verworfenen Daten. Und dann erstellen Sie Ihre inversen Funktionen so, dass sie die verworfenen Daten wie folgt aufnehmen:
s[j] = f'[j](s[j+1], x[j], r[j])
Zusätzlich zu f[j]
und x[j]
müssen Sie auch r[j]
für jede Funktion speichern . Wenn Sie erneut suchen möchten, müssen Sie Lesezeichen speichern, z. B. mit Methode 4.
Für Schach wäre dies dasselbe wie für Methode 2, aber im Gegensatz zu Methode 2, die nur sagt, welche Figur wohin geht, müssen Sie auch speichern, woher jede Figur stammt.
Implementierung:
Da dies für alle Arten von Zuständen mit allen Arten von Funktionen für ein bestimmtes Spiel funktioniert, können Sie verschiedene Annahmen treffen, die die Implementierung erleichtern. Wenn Sie Methode 6 mit dem gesamten Spielstatus implementieren, können Sie nicht nur die Daten wiedergeben, sondern auch die Zeit zurückverfolgen und das Spiel zu einem bestimmten Zeitpunkt fortsetzen. Das wäre ziemlich genial.
Anstatt den gesamten Spielstatus zu speichern, können Sie einfach das Nötigste speichern, das Sie zum Zeichnen eines bestimmten Status benötigen, und diese Daten für einen festgelegten Zeitraum serialisieren. Ihre Zustände sind diese Serialisierungen, und Ihre Eingabe ist jetzt der Unterschied zwischen zwei Serialisierungen. Der Schlüssel dafür ist, dass sich die Serialisierung nur geringfügig ändern sollte, wenn sich auch der Weltstaat nur geringfügig ändert. Dieser Unterschied ist vollständig umkehrbar, so dass die Implementierung von Methode 5 mit Lesezeichen sehr gut möglich ist.
Ich habe dies in einigen großen Spielen implementiert gesehen, hauptsächlich für die sofortige Wiedergabe der neuesten Daten, wenn ein Ereignis eintritt (ein Teil in fps oder eine Punktzahl in Sportspielen).
Ich hoffe, diese Erklärung war nicht zu langweilig.
¹ Dies bedeutet nicht, dass einige Programme nicht deterministisch sind (z. B. MS Windows ^^). Im Ernst, wenn Sie ein nicht deterministisches Programm auf einem deterministischen Computer erstellen können, können Sie ziemlich sicher sein, dass Sie gleichzeitig die Fields-Medaille, den Turing-Preis und wahrscheinlich sogar einen Oscar und einen Grammy für alles gewinnen, was es wert ist.