È possibile visualizzare il sistema come se fosse composto da una serie di stati e funzioni, in cui una funzione f[j]
con input x[j]
modifica lo stato del sistema s[j]
in stato s[j+1]
, in questo modo:
s[j+1] = f[j](s[j], x[j])
Uno stato è la spiegazione di tutto il tuo mondo. Le posizioni del giocatore, la posizione del nemico, il punteggio, le munizioni rimanenti, ecc. Tutto il necessario per disegnare una cornice del gioco.
Una funzione è tutto ciò che può influenzare il mondo. Un cambio di frame, un tasto premuto, un pacchetto di rete.
L'input sono i dati acquisiti dalla funzione. Un cambio di fotogramma può richiedere la quantità di tempo trascorsa dall'ultimo fotogramma passato, la pressione del tasto può includere l'effettivo tasto premuto, nonché se il tasto Maiusc è stato premuto o meno.
Per motivi di questa spiegazione, farò le seguenti ipotesi:
Assunzione 1:
La quantità di stati per una determinata corsa del gioco è molto più grande della quantità di funzioni. Probabilmente hai centinaia di migliaia di stati, ma solo una dozzina di funzioni (cambio di frame, keypress, pacchetto di rete, ecc.). Naturalmente, la quantità di input deve essere uguale alla quantità di stati meno uno.
Assunzione 2:
Il costo spaziale (memoria, disco) della memorizzazione di un singolo stato è molto maggiore di quello della memorizzazione di una funzione e del suo input.
Assunzione 3:
Il costo temporale (tempo) della presentazione di uno stato è simile, o solo uno o due ordini di grandezza più lunghi di quelli del calcolo di una funzione su uno stato.
A seconda dei requisiti del tuo sistema di riproduzione, esistono diversi modi per implementare un sistema di riproduzione, quindi possiamo iniziare con quello più semplice. Farò anche un piccolo esempio usando il gioco degli scacchi, registrato su pezzi di carta.
Metodo 1:
Store s[0]...s[n]
. Questo è molto semplice, molto semplice. A causa del presupposto 2, il costo spaziale di questo è piuttosto elevato.
Per gli scacchi, questo sarebbe realizzato disegnando l'intera scacchiera per ogni mossa.
Metodo 2:
Se hai solo bisogno di replay in avanti, puoi semplicemente archiviare s[0]
e quindi memorizzare f[0]...f[n-1]
(ricorda, questo è solo il nome dell'id della funzione) e x[0]...x[n-1]
(qual è stato l'input per ciascuna di queste funzioni). Per riprodurre, devi semplicemente iniziare s[0]
e calcolare
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
e così via...
Voglio fare una piccola annotazione qui. Diversi altri commentatori hanno affermato che il gioco "deve essere deterministico". Chiunque dica che ha bisogno di prendere di nuovo Computer Science 101, perché a meno che il gioco non debba essere eseguito su computer quantistici, TUTTI I PROGRAMMI INFORMATICI SONO DETERMINISTICI¹. Questo è ciò che rende i computer così fantastici.
Tuttavia, poiché il tuo programma dipende molto probabilmente da programmi esterni, che vanno dalle librerie all'implementazione effettiva della CPU, assicurarsi che le tue funzioni si comportino allo stesso modo tra le piattaforme potrebbe essere piuttosto difficile.
Se si utilizzano numeri pseudo-casuali, è possibile memorizzare i numeri generati come parte dell'input x
oppure memorizzare lo stato della funzione prng come parte del proprio stato s
e la sua implementazione come parte della funzione f
.
Per gli scacchi, questo sarebbe realizzato disegnando la scacchiera iniziale (che è nota) e quindi descrivendo ogni mossa dicendo quale pezzo è andato dove. A proposito, in questo modo lo fanno davvero.
Metodo 3:
Ora, molto probabilmente vorrai essere in grado di cercare nel tuo replay. Cioè, calcolare s[n]
per un arbitrario n
. Utilizzando il metodo 2, è necessario calcolare s[0]...s[n-1]
prima di poter calcolare s[n]
, che, secondo l'ipotesi 2, potrebbe essere piuttosto lento.
Per implementarlo, il metodo 3 è una generalizzazione dei metodi 1 e 2: store f[0]...f[n-1]
e x[0]...x[n-1]
proprio come il metodo 2, ma anche store s[j]
, per tutti j % Q == 0
per una data costante Q
. In termini più semplici, ciò significa che memorizzi un segnalibro in uno di tutti gli Q
stati. Ad esempio, per Q == 100
, si memorizzas[0], s[100], s[200]...
Per calcolare s[n]
un arbitrario n
, devi prima caricare quello precedentemente memorizzato s[floor(n/Q)]
, quindi calcolare tutte le funzioni da floor(n/Q)
a n
. Al massimo, calcolerai le Q
funzioni. Valori più piccoli di Q
sono più veloci da calcolare ma consumano molto più spazio, mentre valori più grandi di Q
consumano meno spazio, ma impiegano più tempo per il calcolo.
Il metodo 3 con Q==1
è uguale al metodo 1, mentre il metodo 3 con Q==inf
è uguale al metodo 2.
Per gli scacchi, questo sarebbe realizzato disegnando ogni mossa, nonché una su ogni 10 tavole (per Q==10
).
Metodo 4:
Se si vuole invertire la riproduzione, è possibile effettuare una piccola variazione del metodo di 3. Si supponga Q==100
, e si vuole calcolare s[150]
attraverso s[90]
in senso inverso. Con il metodo 3 non modificato, sarà necessario eseguire 50 calcoli per ottenere, s[150]
quindi altri 49 calcoli per ottenere s[149]
e così via. Ma poiché hai già calcolato s[149]
per ottenere s[150]
, puoi creare una cache con s[100]...s[150]
quando esegui il calcolo s[150]
per la prima volta, e quindi sei già s[149]
nella cache quando devi visualizzarla.
Hai solo bisogno di rigenerare la cache ogni volta che devi calcolare s[j]
, j==(k*Q)-1
per ogni dato k
. Questa volta, aumentando si Q
avranno dimensioni inferiori (solo per la cache), ma tempi più lunghi (solo per ricreare la cache). È Q
possibile calcolare un valore ottimale per se si conoscono le dimensioni e i tempi richiesti per calcolare stati e funzioni.
Per gli scacchi, questo sarebbe realizzato disegnando ogni mossa, oltre a una su ogni 10 tavole (per Q==10
), ma avrebbe anche bisogno di disegnare in un pezzo di carta separato, le ultime 10 tavole che hai calcolato.
Metodo 5:
Se gli stati consumano semplicemente troppo spazio o le funzioni consumano troppo tempo, è possibile creare una soluzione che implementa effettivamente (non falsa) la riproduzione inversa. Per fare ciò, è necessario creare funzioni inverse per ciascuna delle funzioni disponibili. Tuttavia, ciò richiede che ciascuna delle tue funzioni sia un'iniezione. Se questo è fattibile, quindi per f'
indicare l'inverso della funzione f
, il calcolo s[j-1]
è semplice come
s[j-1] = f'[j-1](s[j], x[j-1])
Si noti che qui, la funzione e l'input sono entrambi j-1
, no j
. Questa stessa funzione e input sarebbero quelli che avresti usato se stessi calcolando
s[j] = f[j-1](s[j-1], x[j-1])
Creare l'inverso di queste funzioni è la parte difficile. Tuttavia, di solito non è possibile, poiché alcuni dati di stato vengono solitamente persi dopo ogni funzione in un gioco.
Questo metodo, così com'è, può invertire il calcolo s[j-1]
, ma solo se lo hai s[j]
. Ciò significa che puoi solo guardare il replay all'indietro, a partire dal punto in cui hai deciso di ripeterlo all'indietro. Se si desidera riprodurre all'indietro da un punto arbitrario, è necessario mescolarlo con il metodo 4.
Per gli scacchi, questo non può essere implementato, poiché con una determinata tavola e la mossa precedente, puoi sapere quale pezzo è stato spostato, ma non da dove si è mosso.
Metodo 6:
Infine, se non puoi garantire che tutte le tue funzioni siano iniezioni, puoi fare un piccolo trucco per farlo. Invece di fare in modo che ogni funzione restituisca solo un nuovo stato, puoi anche far sì che restituisca i dati scartati, in questo modo:
s[j+1], r[j] = f[j](s[j], x[j])
Dove r[j]
sono i dati scartati. E quindi crea le tue funzioni inverse in modo che prendano i dati scartati, in questo modo:
s[j] = f'[j](s[j+1], x[j], r[j])
Oltre a f[j]
e x[j]
, è necessario anche memorizzare r[j]
per ciascuna funzione. Ancora una volta, se si desidera poter cercare, è necessario memorizzare i segnalibri, ad esempio con il metodo 4.
Per gli scacchi, questo sarebbe lo stesso del metodo 2, ma a differenza del metodo 2, che dice solo quale pezzo va dove, devi anche conservare da dove proviene ogni pezzo.
Implementazione:
Dal momento che questo funziona per tutti i tipi di stati, con tutti i tipi di funzioni, per un gioco specifico, puoi fare diversi presupposti, che renderanno più semplice l'implementazione. In realtà, se implementi il metodo 6 con l'intero stato del gioco, non solo sarai in grado di riprodurre i dati, ma anche tornare indietro nel tempo e riprendere a giocare da un dato momento. Sarebbe fantastico.
Invece di memorizzare tutto lo stato del gioco, puoi semplicemente archiviare il minimo indispensabile per disegnare un determinato stato e serializzare questi dati per un periodo di tempo fisso. I tuoi stati saranno queste serializzazioni e il tuo input sarà ora la differenza tra due serializzazioni. La chiave per far funzionare tutto questo è che la serializzazione dovrebbe cambiare poco se anche lo stato mondiale cambia poco. Questa differenza è completamente inversibile, quindi è molto possibile implementare il metodo 5 con i segnalibri.
L'ho visto implementato in alcuni giochi importanti, principalmente per la riproduzione istantanea di dati recenti quando si verifica un evento (un frammento in fps o un punteggio nei giochi sportivi).
Spero che questa spiegazione non sia stata troppo noiosa.
¹ Ciò non significa che alcuni programmi si comportino come se non fossero deterministici (come MS Windows ^^). Ora seriamente, se riesci a fare un programma non deterministico su un computer deterministico, puoi essere abbastanza sicuro che vincerai contemporaneamente la medaglia Fields, il premio Turing e probabilmente anche un Oscar e Grammy per tutto ciò che vale.