Alors, comment pourrais-je concevoir un système de rejeu?
Vous le savez peut-être dans certains jeux tels que Warcraft 3 ou Starcraft, où vous pourrez regarder le jeu à nouveau après l'avoir joué.
Vous vous retrouvez avec un fichier de relecture relativement petit. Donc mes questions sont:
- Comment sauvegarder les données? (format personnalisé?) (petite taille de fichier)
- Qu'est-ce qui sera sauvé?
- Comment le rendre générique afin qu'il puisse être utilisé dans d'autres jeux pour enregistrer une période de temps (et non une correspondance complète par exemple)?
- Permettre d'avancer et de revenir en arrière (WC3 ne pouvait pas revenir en arrière autant que je me souvienne)
Réponses:
Cet excellent article couvre bon nombre des problèmes: http://www.gamasutra.com/view/feature/2029/developing_your_own_replay_system.php
Quelques choses que l'article mentionne et fait bien:
Un moyen d'améliorer encore le taux de compression dans la majorité des cas consiste à découpler tous vos flux d'entrée et à les encoder de manière totalement continue. Ce sera une victoire sur la technique d'encodage delta si vous encodez votre exécution en 8 bits et que l'exécution elle-même dépasse 8 images (très probablement à moins que votre jeu ne soit un véritable masher). J'ai utilisé cette technique dans un jeu de course pour compresser 8 minutes d'entrées de 2 joueurs tout en courant sur une piste jusqu'à quelques centaines d'octets.
Pour ce qui est de rendre un tel système réutilisable, le système de relecture s’occupe des flux d’entrée génériques, mais fournit également des points d’accès permettant à la logique propre au jeu de cibler l’entrée clavier / manette de jeu / souris de ces flux.
Si vous souhaitez un retour rapide ou des recherches aléatoires, vous pouvez enregistrer un point de contrôle (votre statut de jeu complet) toutes les N images. N doit être choisi pour minimiser la taille du fichier de relecture et pour vous assurer que le temps que le joueur doit attendre est raisonnable pendant que l’état est relu au point choisi. Une façon de contourner ce problème consiste à s'assurer que des recherches aléatoires ne peuvent être effectuées que vers ces emplacements de points de contrôle exacts. Le rembobinage consiste à définir l'état du jeu sur le point de contrôle immédiatement avant le cadre en question, puis à rejouer les entrées jusqu'à ce que vous obteniez le cadre actuel. Cependant, si N est trop grand, vous pourriez avoir de l'attelage toutes les quelques images. Un moyen de remédier à ces problèmes consiste à pré-mettre en cache de manière asynchrone les images situées entre les 2 points de contrôle précédents lors de la lecture d'une image mise en cache à partir de la région de point de contrôle actuelle.
la source
Outre la solution "Assurez-vous que les frappes au clavier sont rejouables", ce qui peut être étonnamment difficile, vous pouvez simplement enregistrer l'état complet du jeu sur chaque image. Avec un peu de compression intelligente, vous pouvez le réduire considérablement. C'est ainsi que Braid gère son code de rembobinage temporel et qu'il fonctionne plutôt bien.
Étant donné que vous aurez quand même besoin de points de contrôle pour le rembobinage, vous voudrez peut-être simplement essayer de le mettre en œuvre simplement avant de compliquer les choses.
la source
n
secondes de le jeu.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éex[j]
modifie l'état du systèmes[j]
en états[j+1]
, comme suit: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 stockerf[0]...f[n-1]
(rappelez-vous, il ne s'agit que du nom de l'id de la fonction) etx[0]...x[n-1]
(quelle était l'entrée pour chacune de ces fonctions). Pour rejouer, vous commencez simplement pars[0]
, et calculezetc...
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 états
et son implémentation dans le cadre de la fonctionf
.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 arbitrairen
. En utilisant la méthode 2, vous devez calculers[0]...s[n-1]
avant de pouvoir calculers[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]
etx[0]...x[n-1]
tout comme la méthode 2, mais aussi stockers[j]
, pour tousj % Q == 0
pour une constante donnéeQ
. En termes plus simples, cela signifie que vous stockez un signet dans l'un desQ
États. Par exemple, pourQ == 100
, vous stockezs[0], s[100], s[200]...
Afin de calculer
s[n]
pour un arbitrairen
, vous chargez d'abord le précédemment stockés[floor(n/Q)]
, puis calculez toutes les fonctions defloor(n/Q)
àn
. Tout au plus, vous calculerez desQ
fonctions. Les valeurs plus petites deQ
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 avecQ==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 calculers[150]
pars[90]
en sens inverse. Avec la méthode 3 non modifiée, vous devrez effectuer 50 calculs pour obtenirs[150]
puis 49 autres calculs pour obtenirs[149]
et ainsi de suite. Mais puisque vous avez déjà calculés[149]
pour obtenirs[150]
, vous pouvez créer un cache avecs[100]...s[150]
lorsque vous calculezs[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]
, pourj==(k*Q)-1
pour toutk
. Cette fois-ci, une augmentationQ
entraînera une taille plus petite (uniquement pour le cache), mais des temps plus longs (juste pour recréer le cache). Une valeur optimale pourQ
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 fonctionf
, le calculs[j-1]
est aussi simple queNotez que, ici, la fonction et l'entrée sont les deux
j-1
, pasj
. Cette même fonction et cette entrée seraient celles que vous auriez utilisées si vous calculiezLa 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’avezs[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:
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:En plus de
f[j]
etx[j]
, vous devez également stockerr[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.
la source
Une chose que d’autres réponses n’ont pas encore couverte est le danger des flotteurs. Vous ne pouvez pas faire une application totalement déterministe en utilisant des flottants.
En utilisant des floats, vous pouvez avoir un système complètement déterministe, mais seulement si:
En effet, la représentation interne des flotteurs varie d'un processeur à l'autre, notamment entre les processeurs AMD et intel. Tant que les valeurs sont dans les registres FPU, elles sont plus précises qu'elles ne le sont du côté C, de sorte que tous les calculs intermédiaires sont effectués avec une plus grande précision.
Il est tout à fait évident que cela affectera le bit AMD vs Intel - supposons par exemple que l'un utilise des flottants de 80 bits et que l'autre 64, par exemple - mais pourquoi la même exigence binaire?
Comme je l'ai dit, la précision la plus élevée est utilisée tant que les valeurs sont dans les registres FPU . Cela signifie que chaque fois que vous recompilez, l'optimisation de votre compilateur peut échanger des valeurs dans les registres FPU et en sortir, produisant des résultats légèrement différents.
Vous pourrez peut-être résoudre ce problème en définissant les indicateurs _control87 () / _ controlfp () de manière à utiliser la précision la plus faible possible. Cependant, certaines bibliothèques peuvent aussi toucher à cela (au moins une version de d3d l'a fait).
la source
Sauvegardez l'état initial de vos générateurs de nombres aléatoires. Puis sauvegardez, horodaté, chaque entrée (souris, clavier, réseau, peu importe). Si vous avez un jeu en réseau, vous avez probablement déjà tout cela en place.
Reconfigurez les RNG et reproduisez l'entrée. C'est ça.
Cela ne résout pas le problème du rembobinage, pour lequel il n’existe pas de solution générale, à part reproduire le plus rapidement possible. Vous pouvez améliorer les performances à cet égard en contrôlant l'état complet du jeu toutes les X secondes. Vous n'aurez alors besoin que de rejouer le même nombre de fois. Toutefois, l'état de jeu entier peut également s'avérer extrêmement coûteux.
Les particularités du format de fichier importent peu, mais la plupart des moteurs disposent déjà d’un moyen de sérialiser les commandes et de les indiquer - pour la mise en réseau, la sauvegarde, etc. Il suffit d'utiliser ça.
la source
Je voterais contre le rejeu déterministe. C’est beaucoup plus simple et moins dangereux d’erreurs de sauvegarder l’état de chaque entité tous les 1 / Nème de seconde.
Enregistrez uniquement ce que vous souhaitez afficher lors de la lecture - s'il ne s'agit que de la position et du titre, d'accord, si vous souhaitez également afficher les statistiques, sauvegardez-le également, mais en général, économisez le moins possible.
Tweak l'encodage. Utilisez le moins de bits possible pour tout. La rediffusion ne doit pas nécessairement être parfaite tant qu'elle est suffisamment belle. Même si vous utilisez un float pour, par exemple, l'en-tête, vous pouvez l'enregistrer dans un octet et obtenir 256 valeurs possibles (précision de 1,4º). Cela peut suffire ou même trop pour votre problème particulier.
Utilisez le codage delta. Sauf si vos entités se téléportent (et si elles le font, traitez le cas séparément), encodez les positions comme faisant la différence entre la nouvelle position et l'ancienne position - pour des mouvements courts, vous pouvez vous en tirer avec beaucoup moins de bits que nécessaire pour des positions complètes. .
Si vous voulez revenir en arrière facilement, ajoutez des images clés (données complètes, pas de deltas) toutes les N images. De cette façon, vous pouvez vous en tirer avec une précision moindre pour les deltas et autres valeurs. Les erreurs d'arrondi ne seront plus aussi problématiques si vous réinitialisez périodiquement les valeurs "vraies".
Enfin, gzip le tout :)
la source
C'est dur. Tout d'abord, et surtout, lisez les réponses de Jari Komppa.
Une relecture effectuée sur mon ordinateur risque de ne pas fonctionner sur votre ordinateur car le résultat de flottement est légèrement différent. C'est une grosse affaire.
Mais après cela, si vous avez des nombres aléatoires, vous devez stocker la valeur initiale dans la relecture. Chargez ensuite tous les états par défaut et définissez le nombre aléatoire sur cette valeur initiale. À partir de là, vous pouvez simplement enregistrer l’état actuel de la clé / de la souris et la durée de son utilisation. Ensuite, exécutez tous les événements en utilisant cela comme entrée.
Pour contourner les fichiers (ce qui est beaucoup plus difficile), vous devez vider THE MEMORY. Par exemple, où chaque unité se trouve, l'argent, la durée écoulée, tout l'état du jeu. Puis avancez rapidement mais rejouez tout, sauf le rendu, le son etc. jusqu'à ce que vous arriviez à la destination de votre choix. Cela peut se produire toutes les minutes ou toutes les 5 minutes, en fonction de la rapidité de la transmission.
Les points principaux sont - Faire face à des nombres aléatoires - Copier une entrée (joueur (s), et joueur (s) distant (s)) - Etat de décharge pour sauter des fichiers et ...
la source
Je suis un peu surpris que personne ne mentionne cette option, mais si votre jeu comporte un composant multijoueur, vous avez peut-être déjà effectué beaucoup de travail acharné pour cette fonctionnalité. Après tout, qu’est-ce que le multijoueur est une tentative de rejouer les mouvements de quelqu'un d’autre à un moment (légèrement) différent sur votre propre ordinateur?
Cela vous permet également de profiter d'une taille de fichier réduite comme effet secondaire, à supposer que vous travailliez sur un code réseau convivial pour la bande passante.
À bien des égards, il combine les options "être extrêmement déterministe" et "garder une trace de tout". Vous aurez toujours besoin de déterminisme - si votre rejeu est essentiellement constitué de bots qui reprennent le jeu exactement comme vous le jouiez à l’origine, quelles que soient les actions qu’ils prennent qui peuvent avoir des résultats aléatoires doivent avoir le même résultat.
Le format des données pourrait être aussi simple qu’une simple décharge du trafic réseau, bien que j’imagine qu’il ne serait pas bon de le nettoyer un peu (vous n’aurez pas à vous soucier du décalage lors d’une nouvelle lecture, après tout). Vous pouvez ne rejouer qu'une partie du jeu en utilisant le mécanisme de point de contrôle déjà mentionné - généralement, un jeu multijoueur enverra de temps en temps un état complet de la mise à jour du jeu. Vous aurez donc peut-être déjà effectué ce travail.
la source
Pour obtenir le fichier de relecture le plus petit possible, vous devez vous assurer que votre jeu est déterministe. Cela implique généralement de regarder votre générateur de nombre aléatoire et de voir où il est utilisé dans la logique de jeu.
Vous aurez probablement besoin d'un RNG de logique de jeu et d'un RNG pour tout le reste pour des éléments tels que l'interface graphique, les effets de particules et les sons. Une fois que cela est fait, vous devez enregistrer l’état initial de la logique de jeu RNG, puis les commandes de jeu de tous les joueurs à chaque image.
Pour de nombreux jeux, il existe un niveau d'abstraction entre l'entrée et la logique de jeu où l'entrée est transformée en commandes. Par exemple, si vous appuyez sur le bouton A du contrôleur, la commande numérique "jump" est définie sur true et la logique de jeu réagit aux commandes sans vérifier directement le contrôleur. En faisant cela, vous n'aurez plus qu'à enregistrer les commandes qui ont un impact sur la logique de jeu (pas besoin d'enregistrer la commande "Pause") et très probablement, ces données seront plus petites que l'enregistrement des données du contrôleur. Vous n'avez également pas à vous soucier de l'enregistrement de l'état du schéma de contrôle au cas où le joueur déciderait de remapper les boutons.
Le rembobinage est un problème difficile en utilisant la méthode déterministe et en dehors de la capture instantanée de l’état du jeu et de la transmission rapide au moment voulu, vous ne pouvez rien faire d’autre que d’enregistrer l’état complet du jeu à chaque image.
D'autre part, l'avance rapide est certainement faisable. Tant que votre logique de jeu ne dépend pas de votre rendu, vous pouvez l'exécuter autant de fois que vous le souhaitez avant de rendre une nouvelle image du jeu. La vitesse d'avance rapide sera simplement liée à votre machine. Si vous souhaitez avancer par incréments importants, vous devez utiliser la même méthode d'instantané que celle dont vous auriez besoin pour le rembobinage.
La partie la plus importante de l’écriture d’un système de relecture qui repose sur le déterminisme consiste probablement à enregistrer un flux de données de débogage. Ce flux de débogage contient un instantané contenant le plus d’informations possible à chaque image (semences RNG, transformations d’entités, animations, etc.) et permet de tester ce flux de débogage enregistré par rapport à l’état du jeu lors des replays. Cela vous permettra de vous informer rapidement des incohérences à la fin d'une image donnée. Cela vous épargnera d'innombrables heures à vouloir vous arracher les cheveux de bugs inconnus non déterministes. Quelque chose d'aussi simple qu'une variable non initialisée va tout gâcher à la 11e heure.
NOTE: Si votre jeu implique une diffusion dynamique de contenu ou si vous avez une logique de jeu sur plusieurs threads ou sur différents cœurs ... bonne chance.
la source
Pour activer à la fois l'enregistrement et le rembobinage, enregistrez tous les événements (générés par l'utilisateur, générés par la minuterie, la communication générée, ...).
Pour chaque heure d'enregistrement d'événement, ce qui a été modifié, les valeurs précédentes, les nouvelles valeurs.
Les valeurs calculées ne doivent pas être enregistrées sauf si le calcul est aléatoire
(dans ce cas, vous pouvez également enregistrer les valeurs calculées ou enregistrer les modifications apportées aux semences après chaque calcul aléatoire).
Les données enregistrées sont une liste de modifications.
Les modifications peuvent être enregistrées dans différents formats (binaire, xml, ...).
Le changement consiste en un identifiant d'entité, un nom de propriété, une ancienne valeur, une nouvelle valeur.
Assurez-vous que votre système peut lire ces modifications (accès à l'entité souhaitée, modification de la propriété souhaitée en passant au nouvel état ou retour à l'ancien).
Exemple:
Pour permettre un rembobinage / une avance rapide ou l'enregistrement plus rapide de certaines plages de temps,
des images clés sont nécessaires - si vous enregistrez tout le temps, sauvegardez de temps en temps l'état du jeu dans son intégralité.
Si vous n'enregistrez que certaines plages de temps, enregistrez au début l'état initial.
la source
Si vous avez besoin d’idées sur la façon de mettre en œuvre votre système de relecture, recherchez sur Google pour savoir comment mettre en oeuvre undo / redo. dans une application. Cela peut sembler évident pour certains, mais peut-être pas pour tous, que annuler / rétablir est conceptuellement identique à rejouer pour des jeux. Il s’agit simplement d’un cas particulier dans lequel vous pouvez revenir en arrière et, en fonction de l’application, rechercher à un moment précis.
Vous verrez que personne n'implémentant undo / redo se plaint de variables déterministes / non déterministes, de variables flottantes ou de processeurs spécifiques.
la source