Advertisement
  1. Code
  2. Coding Fundamentals
  3. Game Development

Como construir um sistema de volta no tempo como em Prince Of Persia, parte 2

Scroll to top
Read Time: 11 min
This post is part of a series called How to Build a Prince-Of-Persia-Style Time-Rewind System.
How to Build a Prince-of-Persia-Style Time-Rewind System, Part 1

() translation by (you can also view the original English article)

Final product imageFinal product imageFinal product image
What You'll Be Creating

Da última vez nós criamos um jogo simples onde nós podíamos voltar no tempo para um ponto anterior. Agora vamos solidificar esse recurso e torná-lo muito mais divertido de usar.

Tudo o que faremos aqui será em cima da parte anterior, então dê uma olhada! Como antes, você precisa do Unity e de um entendimento básico da ferramenta.

Pronto? Vamos lá!

Gravar menos dados e interpolar

Nós gravamos as posições e rotações do jogador 50 vezes por segundo. Essa quantidade de dados rapidamente se tornará insustentável, e isto se tornará especialmente perceptível com configurações de jogos mais complexas e dispositivos móveis com menor poder de processamento.

O que podemos fazer, em vez disso, é gravar apenas 4 vezes por segundo e interpolar entre os quadros-chave. Assim podemos economizar 92% de processamento e obter resultados que são indistinguíveis das gravações de 50 quadros, já que eles são executados em frações de segundo.

Vamos começar gravando apenas um quadro-chave a cada x quadros. Para fazer isso, primeiro precisamos destas novas variáveis:

1
public int keyframe = 5;
2
 private int frameCounter = 0;

A variável keyframe é o quadro no método FixedUpdate no qual vamos gravar os dados do jogador. Por enquanto, definimos como 5, o que significa que na quinta vez do ciclo do método FixedUpdate os dados serão registrados. Como FixedUpdate é executado 50 vezes por segundo, isso significa que serão gravados 10 quadros por segundo, em comparação com os 50 de antes. A variável frameCounter será usada para contar os quadros até o próximo quadro-chave.

Agora adapte o bloco de gravação na função FixedUpdate para ficar assim:

1
if(!isReversing)
2
 {
3
     if(frameCounter < keyframe)
4
     {
5
         frameCounter += 1;
6
     }
7
     else
8
     {
9
         frameCounter = 0;
10
         playerPositions.Add (player.transform.position);
11
         playerRotations.Add (player.transform.localEulerAngles);
12
     }
13
 }

Se você experimentá-lo agora, você verá que a volta no tempo está mais curta do que antes. Isso é porque nós gravamos menos dados, mas reproduzimos na velocidade normal. Agora precisamos mudar isso.

Primeiro, precisamos de outra variável chamada frameCounter não para gravar dados, mas para executá-los.

1
private int reverseCounter = 0;

Adapte o código que restaura a posição do jogador para utilizar isto da mesma maneira que nós gravamos os dados. A função FixedUpdate deve ficar assim:

1
void FixedUpdate()
2
 {
3
     if(!isReversing)
4
     {
5
         if(frameCounter < keyframe)
6
         {
7
             frameCounter += 1;
8
         }
9
         else
10
         {
11
             frameCounter = 0;
12
             playerPositions.Add (player.transform.position);
13
             playerRotations.Add (player.transform.localEulerAngles);
14
         }
15
     }
16
     else
17
     {
18
         if(reverseCounter > 0)
19
         {
20
             reverseCounter -= 1;
21
         }
22
         else
23
         {
24
             player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1];
25
             playerPositions.RemoveAt(playerPositions.Count - 1);
26
 
27
             player.transform.localEulerAngles = (Vector3) playerRotations[playerRotations.Count - 1];
28
             playerRotations.RemoveAt(playerRotations.Count - 1);
29
             
30
             reverseCounter = keyframe;
31
         }
32
     }
33
 }

Agora, quando você volta no tempo, o jogador irá saltar de volta para suas posições anteriores, em tempo real!

Não é bem o que queremos, na verdade. Precisamos interpolar entre os quadros-chave, o que será um pouco mais complicado. Primeiro, precisamos dessas quatro variáveis:

1
private Vector3 currentPosition;
2
 private Vector3 previousPosition;
3
 private Vector3 currentRotation;
4
 private Vector3 previousRotation;

Isso salvará os dados atuais do jogador e do quadro-chave gravado antes para que possamos interpolar entre os dois.

Então, temos esta função:

1
void RestorePositions()
2
 {
3
     int lastIndex = keyframes.Count - 1;
4
     int secondToLastIndex = keyframes.Count - 2;
5
 
6
     if(secondToLastIndex >= 0)
7
     { 
8
         currentPosition  = (Vector3) playerPositions[lastIndex];
9
         previousPosition = (Vector3) playerPositions[secondToLastIndex];
10
         playerPositions.RemoveAt(lastIndex);
11
         
12
         currentRotation  = (Vector3) playerRotations[lastIndex];
13
         previousRotation = (Vector3) playerRotations[secondToLastIndex];
14
         playerRotations.RemoveAt(lastIndex);
15
     }
16
 }

Isto irá atribuir as informações correspondentes às variáveis de posição e a rotação para podermos interpolar. Precisamos disto em uma função separada, já que ela será chamada em dois pontos diferentes.

Nosso bloco de restauração de dados deve parecer como este:

1
if(reverseCounter > 0)
2
 {
3
     reverseCounter -= 1;
4
 }
5
 else
6
 {
7
     reverseCounter = keyframe;
8
     RestorePositions();
9
 }
10
 
11
 if(firstRun)
12
 {
13
     firstRun = false;
14
     RestorePositions();
15
 }
16
 
17
 float interpolation = (float) reverseCounter / (float) keyframe;
18
 player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation);
19
 player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation);

Chamamos a função para obter o último e penúltimo set de informação dos nossos vetores sempre que o contador chega no intervalo de frames que digitamos (no caso 5), mas também precisamos chamar isso no primeiro ciclo quando o restauro está acontecendo. É por isso que temos este bloco:

1
if(firstRun)
2
 {
3
     firstRun = false;
4
     RestorePositions();
5
 }

Para que isso funcione, você precisa também da variável firstRun:

1
private bool firstRun = true;

E redefini-la quando o botão de espaço é solto:

1
if(Input.GetKey(KeyCode.Space))
2
 {
3
     isReversing = true;
4
 }
5
 else
6
 {
7
     isReversing = false;
8
     firstRun = true;
9
 }

Eis como funciona a interpolação:

Em vez de usar apenas o último quadro-chave que salvamos, este sistema obtém o último e o penúltimo e interpola entre eles. A quantidade de interpolação baseia-se em quão distantes os quadros estão.

Tudo isto acontece através da função Lerp, onde acrescentamos a posição atual (ou rotação) e a anterior. Então a fração da interpolação é calculada, o que pode ir de 0 a 1. O jogador é colocado no lugar equivalente entre esses dois pontos salvos, por exemplo, 40% na rota para o último quadro-chave.

Quando você diminui a velocidade e executa quadro a quadro, você pode realmente ver o movimento do personagem do jogador entre os quadros-chave, mas no jogo, não é perceptível.

E, assim, reduzimos extremamente a complexidade da implementação e tornamos tudo mais estável.

Gravando apenas um número fixo de quadros-chave

Agora que reduzimos consideravelmente o número de quadros que realmente salvamos, podemos nos certificar que não salvamos dados demais.

Agora temos apenas uma pilha com os dados gravados na matriz, o que não é bom a longo prazo. Conforme a matriz cresce, ela se tornará mais difícil de manejar, acessar levará mais tempo e a instalação inteira vai se tornar mais instável.

Para corrigir isto, nós podemos criar um código que verifica se a matriz cresce até um determinado tamanho. Se sabemos quantos quadros por segundo vamos salvar, podemos determinar quantos segundos de tempo "retornável" devemos guardar, ajustando-se a complexidade do jogo. O sistema do Prince of Persia permite talvez 15 segundos de tempo retorno do tempo, enquanto a configuração simples de Braid permite um retorno ilimitado.

1
if(playerPositions.Count > 128)
2
 {
3
     playerPositions.RemoveAt(0);
4
     playerRotations.RemoveAt(0);
5
 }

O que acontece é que, quando a matriz cresce ao longo de um determinado tamanho, nós removemos a primeira entrada da mesma. Assim, ele só fica o tempo que queremos que o jogador volte, e não há perigo de isso se tornar muito grande para usar eficientemente. Coloque isso na função FixedUpdate após a gravação e a repetição de código.

Use uma classe personalizada para armazenar dados do jogador

Agora, nós gravamos as posições dos jogadores e rotações em duas matrizes separadas. Apesar de funcionar, é preciso lembrar de sempre gravar e acessar os dados em dois lugares ao mesmo tempo, o que pode gerar problemas futuros.

O que podemos fazer, no entanto, é criar uma classe separada para guardar ambas essas coisas e ainda mais (caso isso seja necessário no seu projeto).

O código para uma classe personalizada atuar como um contêiner para os dados é o seguinte:

1
public class Keyframe
2
 {
3
     public Vector3 position;
4
     public Vector3 rotation;
5
 
6
     public Keyframe(Vector3 position, Vector3 rotation)
7
     {
8
         this.position = position;
9
         this.rotation = rotation;
10
     }
11
 }

Você pode adicioná-lo ao arquivo TimeController.cs, logo antes do início da declaração da classe. O que o script faz é fornecer um recipiente para salvar a posição e a rotação do jogador. O método construtor permite que sejam criados diretamente com as informações necessárias.

O resto do algoritmo precisará ser adaptado para trabalhar com o novo sistema. No método Start, a matriz precisa ser inicializada:

1
keyframes = new ArrayList();

Em vez de dizer:

1
playerPositions.Add (player.transform.position);
2
 playerRotations.Add (player.transform.localEulerAngles);

Podemos guardar diretamente em um objeto Keyframe:

1
keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles));

O que fazemos aqui é adicionar a posição e a rotação do jogador para o mesmo objeto, que em seguida é adicionado em uma única matriz, o que reduz a complexidade desta configuração.

Adicionando um efeito de desfoque para sinalizar que a volta no tempo está acontecendo

Precisamos de algum tipo de sinal nos dizendo que o jogo está sendo rebobinado. Nós desenvolvedores sabemos disso, mas um jogador pode ficar confuso. Em tais situações, é bom ter várias coisas dizendo ao jogador que a volta no tempo está acontecendo, como efeitos visuais(borrando a tela um pouco) e áudio (deixando a música mais baixa ou ao contrário).

Vamos fazer algo semelhante ao que é feito em Prince of Persia, com algum efeito de blur.

A screenshot of the time-rewinding from Prince of Persia The Forgotten SandsA screenshot of the time-rewinding from Prince of Persia The Forgotten SandsA screenshot of the time-rewinding from Prince of Persia The Forgotten Sands
Voltando no tempo em Prince of Persia: The Forgotten Sands

Unity permite que você adicione vários efeitos de câmera juntos, e com algumas experiências, você pode fazer esse efeito encaixar-se perfeitamente no seu projeto.

Antes de usarmos os efeitos básicos, precisamos importá-los. Para fazer isso, vá em Assets > Import Package > Effects e importe tudo o que é oferecido.

View of the effects-menu in Unity 3DView of the effects-menu in Unity 3DView of the effects-menu in Unity 3D

Efeitos visuais podem ser adicionados diretamente na câmara principal. Vá em Components > Image Effects e adicione os efeitos Blur e Bloom. A combinação desses dois deve resultar no efeito que queremos.

View of the effects-inspector in Unity 3DView of the effects-inspector in Unity 3DView of the effects-inspector in Unity 3D
Estas são as configurações básicas. Você pode ajustá-las para o seu projeto.

Experimente agora, o jogo terá este efeito o tempo todo.

A screenshot of the effect in useA screenshot of the effect in useA screenshot of the effect in use

Agora precisamos ativá-lo e desativá-lo respectivamente. Por isso, o TimeController precisa importar os efeitos de imagem. Adicione esta linha no início:

1
using UnityStandardAssets.ImageEffects;

Para acessar a câmera do TimeController, adicione essa variável:

1
private Camera camera;

E atribua-o na função Start:

1
camera = Camera.main;

Adicione este código para ativar os efeitos enquanto volta no tempo, e desativa-lo no final:

1
void Update()
2
 {
3
     if(Input.GetKey(KeyCode.Space))
4
     {
5
         isReversing = true;
6
         camera.GetComponent<Blur>().enabled = true;
7
         camera.GetComponent<Bloom>().enabled = true;
8
     }
9
     else
10
     {
11
         isReversing = false;
12
         firstRun = true;
13
         camera.GetComponent<Blur>().enabled = false;
14
         camera.GetComponent<Bloom>().enabled = false;
15
     }
16
 }

Quando você pressiona o botão de espaço, você não apenas começa a voltar no tempo, mas também ativa os efeitos na câmera, dizendo ao jogador que algo está acontecendo.

Todo o código do TimeController deve ficar assim:

1
using UnityEngine;
2
 using System.Collections;
3
 using UnityStandardAssets.ImageEffects;
4
 
5
 public class Keyframe
6
 {
7
     public Vector3 position;
8
     public Vector3 rotation;
9
 
10
     public Keyframe(Vector3 position, Vector3 rotation)
11
     {
12
         this.position = position;
13
         this.rotation = rotation;
14
     }
15
 }
16
 
17
 public class TimeController: MonoBehaviour
18
 {
19
     public GameObject player;
20
     public ArrayList keyframes;
21
     public bool isReversing = false;
22
     
23
     public int keyframe = 5;
24
     private int frameCounter = 0;
25
     private int reverseCounter = 0;
26
     
27
     private Vector3 currentPosition;
28
     private Vector3 previousPosition;
29
     private Vector3 currentRotation;
30
     private Vector3 previousRotation;
31
     
32
     private Camera camera;
33
     
34
     private bool firstRun = true;
35
     
36
     void Start()
37
     {
38
         keyframes = new ArrayList();
39
         camera = Camera.main;
40
     }
41
     
42
     void Update()
43
     {
44
         if(Input.GetKey(KeyCode.Space))
45
         {
46
             isReversing = true;
47
             camera.GetComponent<Blur>().enabled = true;
48
             camera.GetComponent<Bloom>().enabled = true;
49
         }
50
         else
51
         {
52
             isReversing = false;
53
             firstRun = true;
54
             camera.GetComponent<Blur>().enabled = false;
55
             camera.GetComponent<Bloom>().enabled = false;
56
         }
57
     }
58
     
59
     void FixedUpdate()
60
     {
61
         if(!isReversing)
62
         {
63
             if(frameCounter < keyframe)
64
             {
65
                 frameCounter += 1;
66
             }
67
             else
68
             {
69
                 frameCounter = 0;
70
                 keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles));
71
             }
72
         }
73
         else
74
         {
75
             if(reverseCounter > 0)
76
             {
77
                 reverseCounter -= 1;
78
             }
79
             else
80
             {
81
                 reverseCounter = keyframe;
82
                 RestorePositions();
83
             }
84
     
85
             if(firstRun)
86
             {
87
                 firstRun = false;
88
                 RestorePositions();
89
             }
90
     
91
             float interpolation = (float) reverseCounter / (float) keyframe;
92
             player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation);
93
             player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation);
94
         }
95
     
96
         if(keyframes.Count > 128)
97
         {
98
             keyframes.RemoveAt(0);
99
         }
100
     }
101
     
102
     void RestorePositions()
103
     {
104
         int lastIndex = keyframes.Count - 1;
105
         int secondToLastIndex = keyframes.Count - 2;
106
     
107
         if(secondToLastIndex >= 0)
108
         {
109
             currentPosition  = (keyframes[lastIndex] as Keyframe).position;
110
             previousPosition = (keyframes[secondToLastIndex] as Keyframe).position;
111
     
112
             currentRotation  = (keyframes[lastIndex] as Keyframe).rotation;
113
             previousRotation = (keyframes[secondToLastIndex] as Keyframe).rotation;
114
     
115
             keyframes.RemoveAt(lastIndex);
116
         }
117
     }
118
 }

Baixe o pacote compilado anexado e experimente!

Conclusão

Nosso jogo agora está muito melhor que antes. O algoritmo é visivelmente melhor e usa 90% menos poder de processamento, é muito mais estável, e temos um feedback para o usuário quando eles estão voltando no tempo.

Agora faça um jogo usando isso!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.