Quando comecei a programar, acho que, como muitos, eu queria fazer jogos. Mas, diante de mim, havia muitos problemas de arquitetura que eu não sabia como resolver, eu nem ouvia falar em buffer duplo e queria obter o resultado o mais rápido possível. Por isso, recentemente decidi escrever um projeto no qual será possível escrever jogos simples sem problemas. Os jogos deste projeto podem ser criados como GameBoy, ou seja: tetris, cobra, etc. Mas você também pode clicar nele com o mouse.
Link para o projeto no GitHub .
Neste artigo, quero entender a criação de uma cobra.
A primeira coisa com a qual você precisa começar é criar sua própria classe de jogo e herdar da classe base do jogo.
class Snake : Game
ele já implementa o campo de jogo e os eventos que ocorrem quando o jogo faz a transição de um estado para outro. Basicamente, tudo o que precisamos fazer é declarar a manipulação de eventos.
public Snake() : base() { OnPreview += BasePreview; OnNewGame += Snake_OnNewGame; OnUpdateGame += Snake_OnUpdateGame; OnGameOver += DrawScore; }
Para os eventos OnPreview e OnGameOver, já existem stubs prontos na classe Game, você não pode implementá-los. Resta apenas inicializar um novo jogo e processar eventos de atualização.
private GameBlock head; private List<GameBlock> body; private GameBlock eat; private void Snake_OnNewGame() { head = new GameBlock() { X = 10, Y = 10, Vector = Vector.Up, Color = GameColor.Green }; body = new List<GameBlock>(); body.Add( head ); body.Add( new GameBlock() { X = 10, Y = 11, Vector = Vector.Up, Color = GameColor.Black } ); body.Add( new GameBlock() { X = 10, Y = 12, Vector = Vector.Up, Color = GameColor.Black } ); CreateEat(); DrawField(); }
Você pode trabalhar diretamente com ele para desenhar um campo ou usar a classe GameBlock pronta, implementando itens como posição, direção do movimento e cor.
Nesta função, declaramos o corpo da cobra, criamos o primeiro pedaço de comida e exibimos o que está acontecendo no campo.
private void CreateEat() { var emptyBlocks = new List<GameBlock>(); for( int i = 0; i < MainForm.FIELD_SIZE; i++ ) for( int j = 0; j < MainForm.FIELD_SIZE; j++ ) if( CheckEmptyBlock( i, j ) ) emptyBlocks.Add(new GameBlock() { X = i, Y = j, Color = GameColor.Red } ); if (emptyBlocks.Count > 0) eat = emptyBlocks[random.Next( emptyBlocks.Count )]; }
Para criar uma refeição, obtemos uma lista de blocos vazios e, com a ajuda de um randomizador (que já está declarado no jogo), seleciona aleatoriamente. Caso a cobra tenha ocupado o campo inteiro, há uma verificação no tamanho da lista.
Na verdade, a função de verificar a célula vazia:
private bool CheckEmptyBlock(int x, int y) => !( x < 0 || y < 0 || x == MainForm.FIELD_SIZE || y == MainForm.FIELD_SIZE ) && !body.Exists( a => a.Equals( new GameBlock() { X = x, Y = y } ) );
A renderização do campo é a seguinte:
private void DrawField() { Field.Clear( GameColor.White ); Field.DrawGameBlock( eat ); Field.DrawGameBlocks( body ); WriteScore(); }
Como não é difícil adivinhar, o campo é limpo em branco e os alimentos com uma cobra são exibidos. O WriteScore é outro recurso padrão para exibir uma pontuação em uma barra de status especial.
Portanto, passamos ao evento de atualização do jogo, que ocorre com uma frequência de 300 ms.
private void Snake_OnUpdateGame( Controller controller ) { ControlMove( controller.GameKey ); if( CheckGameOver() ) GameOver(); else SnakeMove(); }
Quatro coisas acontecem: mudar a direção do movimento, verificar o final do jogo, chamar o evento do final do jogo e mover a cobra, caso tudo esteja em ordem.
private void ControlMove( GameKey key ) { switch( key ) { case GameKey.Left: head.Vector = head.Vector == Vector.Right ? Vector.Right : Vector.Left; break; case GameKey.Right: head.Vector = head.Vector == Vector.Left ? Vector.Left : Vector.Right; break; case GameKey.Up: head.Vector = head.Vector == Vector.Down ? Vector.Down : Vector.Up; break; case GameKey.Down: head.Vector = head.Vector == Vector.Up ? Vector.Up : Vector.Down; break; default: break; } }
Para mudar a direção do movimento na cobra, precisamos mudar o vetor em sua cabeça. Portanto, no controle do movimento, verifica-se o caso de inversão do vetor, para que a cobra não comece a subir em si mesma.
private bool CheckGameOver() { switch( head.Vector ) { case Vector.Up: return !CheckEmptyBlock( head.X, head.Y - 1 ); case Vector.Down: return !CheckEmptyBlock( head.X, head.Y + 1 ); case Vector.Left: return !CheckEmptyBlock( head.X - 1, head.Y ); case Vector.Right: return !CheckEmptyBlock( head.X + 1, head.Y ); default: throw new NotImplementedException(); } }
Para verificar o final do jogo, basta verificar se o bloco na direção é livre ou não. Como você pode imaginar, a comida na seleção é ignorada.
Resta analisar a função de movimento da cobra:
private void SnakeMove() { var temp = body.Last().Copy(); foreach( var block in body ) block.Move(); for( int i = body.Count - 1; i > 0; i-- ) body[i].Vector = body[i - 1].Vector; if( head.Equals( eat ) ) { score++; body.Add( temp ); CreateEat(); } DrawField(); }
O final da cauda é copiado para que, se o alimento for atingido, adicione-o como uma extensão de cobra. Mover blocos não é difícil, porque essa função já está implementada na classe de blocos. Em seguida, os vetores são distribuídos pelo movimento da cobra e verificados quanto à interseção com os alimentos. Se for encontrado alimento, a conta aumenta, a cobra cresce e novos alimentos são criados. Para que nosso jogo apareça na lista de jogos, você precisa adicioná-lo à inicialização do formulário:
List<Game> games = new List<Game>(); games.Add( new Snake() ); games.Add( new Tetris() ); games.Add( new Life() ); Application.Run( new MainForm( games ) );
Só isso. Todo o código do jogo levou apenas 102 linhas. Como você pode ver no exemplo, tetris e vida do jogo já foram adicionados ao projeto. Abaixo você encontra o resultado.
Menu de seleção de jogos
Processo de jogo
O fim do jogo