Vous pouvez voir votre système comme s'il était composé d'une série d'états et de fonctions, où une fonction f[j]
avec entrée x[j]
modifie l'état du système s[j]
en état s[j+1]
, comme suit:
s[j+1] = f[j](s[j], x[j])
Un état est l'explication de votre monde entier. Les emplacements du joueur, l'emplacement de l'ennemi, le score, les munitions restantes, etc. Tout ce dont vous avez besoin pour dessiner un cadre de votre partie.
Une fonction est tout ce qui peut affecter le monde. Un changement de trame, une pression sur une touche, un paquet réseau.
L'entrée correspond aux données prises par la fonction. Un changement de trame peut prendre le temps écoulé depuis la dernière trame, le fait d'appuyer sur la touche peut inclure la touche réellement enfoncée, ainsi que le fait que la touche Maj soit enfoncée ou non.
Aux fins de cette explication, je vais faire les hypothèses suivantes:
Hypothèse 1:
La quantité d'états pour une exécution donnée du jeu est beaucoup plus grande que la quantité de fonctions. Vous avez probablement des centaines de milliers d'états, mais seulement plusieurs dizaines de fonctions (changement de trame, pression de touche, paquet réseau, etc.). Bien entendu, la quantité d’intrants doit être égale à la quantité d’états moins un.
Hypothèse 2:
Le coût spatial (mémoire, disque) du stockage d'un seul état est beaucoup plus élevé que celui du stockage d'une fonction et de son entrée.
Hypothèse 3:
Le coût temporel (temps) de la présentation d'un état est similaire, ou juste un ou deux ordres de grandeur plus long que celui du calcul d'une fonction sur un état.
Selon les exigences de votre système de lecture, il existe plusieurs façons de mettre en œuvre un système de lecture afin que nous puissions commencer par la plus simple. Je vais aussi faire un petit exemple en utilisant le jeu d’échecs, enregistré sur du papier.
Méthode 1:
Magasin s[0]...s[n]
. C'est très simple, très simple. En raison de l'hypothèse 2, le coût spatial de ceci est assez élevé.
Pour les échecs, cela serait accompli en tirant tout le tableau pour chaque coup.
Méthode 2:
Si vous avez seulement besoin d'une relecture avant, vous pouvez simplement stocker s[0]
, puis stocker f[0]...f[n-1]
(rappelez-vous, il ne s'agit que du nom de l'id de la fonction) et x[0]...x[n-1]
(quelle était l'entrée pour chacune de ces fonctions). Pour rejouer, vous commencez simplement par s[0]
, et calculez
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
etc...
Je veux faire une petite annotation ici. Plusieurs autres commentateurs ont déclaré que le jeu "doit être déterministe". Tous ceux qui disent que cela doit recommencer avec Computer Science 101, car TOUS LES PROGRAMMES INFORMATIQUES SONT DETERMINISTES¹, à moins que votre jeu ne soit conçu pour être exécuté sur des ordinateurs quantiques. C'est ce qui rend les ordinateurs si géniaux.
Cependant, étant donné que votre programme dépend très probablement de programmes externes, allant des bibliothèques à la mise en œuvre réelle du processeur, il peut être assez difficile de s'assurer que vos fonctions se comportent de la même manière entre les plates-formes.
Si vous utilisez des nombres pseudo-aléatoires, vous pouvez soit stocker les nombres générés dans le cadre de votre saisie x
, soit stocker l'état de la fonction prng dans le cadre de votre état s
et son implémentation dans le cadre de la fonction f
.
Pour les échecs, cela serait accompli en tirant le tableau initial (qui est connu), puis en décrivant chaque coup en disant quelle pièce est allée où. C'est d'ailleurs ce qu'ils font en réalité.
Méthode 3:
Maintenant, vous voudrez probablement pouvoir rechercher dans votre relecture. C'est-à-dire, calculez s[n]
pour un arbitraire n
. En utilisant la méthode 2, vous devez calculer s[0]...s[n-1]
avant de pouvoir calculer s[n]
, ce qui, selon l'hypothèse 2, peut être assez lent.
Pour implémenter cela, la méthode 3 est une généralisation des méthodes 1 et 2: stocker f[0]...f[n-1]
et x[0]...x[n-1]
tout comme la méthode 2, mais aussi stocker s[j]
, pour tous j % Q == 0
pour une constante donnée Q
. En termes plus simples, cela signifie que vous stockez un signet dans l'un des Q
États. Par exemple, pour Q == 100
, vous stockezs[0], s[100], s[200]...
Afin de calculer s[n]
pour un arbitraire n
, vous chargez d'abord le précédemment stocké s[floor(n/Q)]
, puis calculez toutes les fonctions de floor(n/Q)
à n
. Tout au plus, vous calculerez des Q
fonctions. Les valeurs plus petites de Q
sont plus rapides à calculer, mais consomment beaucoup plus d'espace, alors que les valeurs plus grandes, Q
consomment moins d'espace, mais prennent plus de temps à calculer.
La méthode 3 avec Q==1
est identique à la méthode 1, tandis que la méthode 3 avec Q==inf
est identique à la méthode 2.
Pour les échecs, cela serait accompli en tirant chaque coup, ainsi qu’un tableau sur 10 (pour Q==10
).
Méthode 4:
Si vous voulez inverser la relecture, vous pouvez faire une petite variation de la méthode 3. Supposons Q==100
, et que vous voulez calculer s[150]
par s[90]
en sens inverse. Avec la méthode 3 non modifiée, vous devrez effectuer 50 calculs pour obtenir s[150]
puis 49 autres calculs pour obtenir s[149]
et ainsi de suite. Mais puisque vous avez déjà calculé s[149]
pour obtenir s[150]
, vous pouvez créer un cache avec s[100]...s[150]
lorsque vous calculez s[150]
pour la première fois, puis vous êtes déjà s[149]
dans le cache lorsque vous devez l'afficher.
Il vous suffit de régénérer le cache chaque fois que vous devez calculer s[j]
, pour j==(k*Q)-1
pour tout k
. Cette fois-ci, une augmentation Q
entraînera une taille plus petite (uniquement pour le cache), mais des temps plus longs (juste pour recréer le cache). Une valeur optimale pour Q
peut être calculée si vous connaissez les tailles et les temps nécessaires au calcul des états et des fonctions.
Pour les échecs, cela se ferait en dessinant chaque coup, ainsi qu’un Q==10
carton sur 10 (pour ), mais aussi, il faudrait dessiner sur un morceau de papier séparé, les 10 derniers tableaux que vous avez calculés.
Méthode 5:
Si les états consomment simplement trop d'espace ou que les fonctions prennent trop de temps, vous pouvez créer une solution qui implémente réellement la relecture inversée (et non des faux). Pour ce faire, vous devez créer des fonctions inverses pour chacune de vos fonctions. Cependant, cela nécessite que chacune de vos fonctions soit une injection. Si cela est faisable, alors pour f'
dénoter l'inverse de fonction f
, le calcul s[j-1]
est aussi simple que
s[j-1] = f'[j-1](s[j], x[j-1])
Notez que, ici, la fonction et l'entrée sont les deux j-1
, pas j
. Cette même fonction et cette entrée seraient celles que vous auriez utilisées si vous calculiez
s[j] = f[j-1](s[j-1], x[j-1])
La création de l'inverse de ces fonctions est la partie la plus délicate. Cependant, vous ne pouvez généralement pas le faire, car certaines données d'état sont généralement perdues après chaque fonction du jeu.
Cette méthode, telle quelle, peut inverser le calcul s[j-1]
, mais seulement si vous l’avez s[j]
. Cela signifie que vous ne pouvez regarder la retransmission qu'en arrière, à partir du moment où vous avez décidé de la rejouer en arrière. Si vous souhaitez rejouer en arrière à partir d'un point arbitraire, vous devez associer cela à la méthode 4.
Pour les échecs, cela ne peut pas être mis en œuvre, car avec un tableau donné et le mouvement précédent, vous pouvez savoir quelle pièce a été déplacée, mais pas d'où elle a été déplacée.
Méthode 6:
Enfin, si vous ne pouvez pas garantir que toutes vos fonctions sont des injections, vous pouvez faire un petit truc pour le faire. Au lieu que chaque fonction renvoie uniquement un nouvel état, vous pouvez également lui demander de renvoyer les données supprimées, comme suit:
s[j+1], r[j] = f[j](s[j], x[j])
Où r[j]
sont les données ignorées. Et créez ensuite vos fonctions inverses afin qu’elles prennent les données supprimées, comme suit:
s[j] = f'[j](s[j+1], x[j], r[j])
En plus de f[j]
et x[j]
, vous devez également stocker r[j]
pour chaque fonction. Encore une fois, si vous voulez pouvoir chercher, vous devez stocker des signets, comme avec la méthode 4.
Pour les échecs, ce serait la même chose que la méthode 2, mais contrairement à la méthode 2, qui indique uniquement quelle pièce va où, vous devez également stocker d'où vient chaque pièce.
La mise en oeuvre:
Étant donné que cela fonctionne pour tous les types d'états, avec toutes sortes de fonctions, pour un jeu spécifique, vous pouvez faire plusieurs hypothèses qui faciliteront sa mise en œuvre. En fait, si vous implémentez la méthode 6 avec l’ensemble de l’état du jeu, vous pourrez non seulement rejouer les données, mais aussi remonter dans le temps et reprendre la lecture à tout moment. Ce serait vraiment génial.
Au lieu de stocker tout l'état du jeu, vous pouvez simplement stocker le minimum nécessaire pour dessiner un état donné et sérialiser ces données chaque fois que vous le souhaitez. Vos états seront ces sérialisations et votre entrée sera maintenant la différence entre deux sérialisations. Pour que cela fonctionne, il est essentiel que la sérialisation change peu si l’état du monde change aussi peu. Cette différence est complètement inversible, la mise en œuvre de la méthode 5 avec des signets est donc très possible.
J'ai vu cela implémenter dans certains jeux majeurs, principalement pour la lecture instantanée de données récentes lorsqu'un événement (un fragment dans une image par seconde ou un score dans un jeu sportif) se produit.
J'espère que cette explication n'était pas trop ennuyeuse.
¹ Cela ne signifie pas que certains programmes agissent comme s'ils n'étaient pas déterministes (comme MS Windows ^^). Maintenant sérieusement, si vous pouvez créer un programme non déterministe sur un ordinateur déterministe, vous pouvez être certain de remporter simultanément la médaille Fields, le prix Turing et probablement même un Oscar et un Grammy pour tout ce que vous méritez.