TOC

This article is currently in the process of being translated into Russian (~98% done).

Создание игры: WPF змейка:
Chapter introduction:

In this article series, we're building a complete Snake game from scratch. It makes sense to start with the Introduction and then work your way through the articles one by one, to get the full understanding.

If you want to get the complete source code for the game at once, to get started modifying and learning from it right now, consider downloading all our samples!

Collision Detection

Теперь, когда мы создали игровое поле, змейку и еду для нее, и реализовали непрерывное движение змейки, нам осталось сделать только одну последнюю штуку, чтобы наша игра выглядела и работала как настоящая: Отслеживание столкновений. Концепция строится на постоянной проверке: не столкнулась ли наша Змейка с чем-либо, ведь нам все время необходимо знать две вещи: не съела ли Змейка какую-то еду, или не столкнулась ли она с препятствием (со стеной или с собственным хвостом).

The DoCollisionCheck() method

Отслеживание столкновений будет выполняться в методе, названном DoCollisionCheck(), а значит, нам нужно его разработать. Вот как это сейчас выглядит:

private void DoCollisionCheck()
{
    SnakePart snakeHead = snakeParts[snakeParts.Count - 1];
   
    if((snakeHead.Position.X == Canvas.GetLeft(snakeFood)) && (snakeHead.Position.Y == Canvas.GetTop(snakeFood)))
    {        
EatSnakeFood();
return;
    }

    if((snakeHead.Position.Y < 0) || (snakeHead.Position.Y >= GameArea.ActualHeight) ||
(snakeHead.Position.X < 0) || (snakeHead.Position.X >= GameArea.ActualWidth))
    {
EndGame();
    }

    foreach(SnakePart snakeBodyPart in snakeParts.Take(snakeParts.Count - 1))
    {
if((snakeHead.Position.X == snakeBodyPart.Position.X) && (snakeHead.Position.Y == snakeBodyPart.Position.Y))
    EndGame();
    }
}

Как и было обещано, мы делам две проверки: Во-первых, смотрим, не совпадает ли текущая позиция головы змейки с позицией кусочка пищи. Если так, мы вызываем метод EatSnakeFood() (подробности ниже). Затем, мы проверяем, не вышла ли позиция головы змейки за пределы игрового пространства, что означало бы, что змейка выползает за одну из установленных границ. Если это так, мы вызываем метод EndGame(). И последнее, что мы проверяем: не совпадает ли позиция головы змейки с положением какой-либо части её тела — если это так, змейка только что столкнулась со своим собственным хвостом, на чем игра также завершается вызовом метода EndGame().

The EatSnakeFood() method

Метод EatSnakeFood() отвечает за две вещи: ведь как только змейка съедает существующий кусочек пищи, мы должны добавить новый, в новом расположении, и, кроме того, изменить счет, длину змейки и текущую скорость игры. Для счета в игре нам нужно объявить новую локальную переменную с именем currentScore:

public partial class SnakeWPFSample : Window  
{  
    ....  
    private int snakeLength;  
    private int currentScore = 0;  
    ....

Разобравшись с этим, добавим метод EatSnakeFood():

private void EatSnakeFood()
{
    snakeLength++;
    currentScore++;
    int timerInterval = Math.Max(SnakeSpeedThreshold, (int)gameTickTimer.Interval.TotalMilliseconds - (currentScore * 2));
    gameTickTimer.Interval = TimeSpan.FromMilliseconds(timerInterval);    
    GameArea.Children.Remove(snakeFood);
    DrawSnakeFood();
    UpdateGameStatus();
}

Как было уже упомянуто, в нем происходит несколько вещей:

  • Мы увеличиваем на единицу переменные snakeLength и currentScore дабы отразить тот факт, что змейка только что проглотила кусочек пищи.
  • Мы изменяем в gameTickTimer значение Interval руководствуясь следующим правилом: Умножаем currentScore на 2 и, затем, вычитаем полученное из текущего значения интервала таймера скорости. Это приводит к экспоненциальному росту скорости, что, вкупе с увеличением длины змейки, делает игру безумно сложной. Ранее мы задали нижнюю границу таймера скорости константой SnakeSpeedThreshold так, что интервал таймера скорости никогда не станет меньше 100 мс.
  • Затем мы убираем съеденную змейкой еду, и вызываем метод DrawSnakeFood() который добавляет новую еду в новую позицию
  • В финале, мы вызываем метод UpdateGameStatus(), который выглядит так:
private void UpdateGameStatus()
{
    this.Title = "SnakeWPF - Score: " + currentScore + " - Game speed: " + gameTickTimer.Interval.TotalMilliseconds;
}

Этот метод попросту обновляет свойство Title окна Window для отображения текущих значений счета и скорости игры. Это простой способ отображения текущего статуса игры, который, по желанию, может быть легко улучшен позднее.

Метод EndGame()

Нам также необходимо выполнить небольшой код при завершении игры. Сделаем это через метод EndGame(), который сейчас вызывается из метода DoCollisionCheck(). Как Вы можете видеть, он весьма прост:

private void EndGame()
{
    gameTickTimer.IsEnabled = false;
    MessageBox.Show("Oooops, you died!\n\nTo start a new game, just press the Space bar...", "SnakeWPF");
}

Помимо демонстрации пользователю сообщения о неудачном прохождении игры нашей любимой змейкой, мы попросту останавливаем gameTickTimer. Поскольку всё в игре происходит по этому таймеру, как только он останавливается, все движения и отрисовки также прекращаются.

Окончательные корректировки

Сейчас наш первый набросок полнофункциональной игры Змейка почти готов — фактически, нам нужно сделать всего две маленькие корректировки. Во-первых, нам нужно убедиться в том, что метод DoCollisionCheck() вызван — это должно произойти как только выполнено последнее действие в методе MoveSnake(), который мы реализовали раньше:

private void MoveSnake()
{
    .....
   
    //... and then have it drawn!
    DrawSnake();
    // Finally: Check if it just hit something!
    DoCollisionCheck();    
}

Теперь отслеживание столкновений выполняется как только змейка совершила движение! А теперь, припоминаете как я говорил Вам, что мы разработали простой вариант метода StartNewGame()? Нам нужно его немного усовершенствовать, чтобы быть уверенными в том, что мы обнуляем счет всякий раз, когда игра перезапускается, ну и плюс еще добавить пару вещей. Итак, заменим метод StartNewGame() этой, слегка расширенной версией:

private void StartNewGame()
{
    // Remove potential dead snake parts and leftover food...
    foreach(SnakePart snakeBodyPart in snakeParts)
    {
if(snakeBodyPart.UiElement != null)
    GameArea.Children.Remove(snakeBodyPart.UiElement);
    }
    snakeParts.Clear();
    if(snakeFood != null)
GameArea.Children.Remove(snakeFood);

    // Reset stuff
    currentScore = 0;
    snakeLength = SnakeStartLength;
    snakeDirection = SnakeDirection.Right;
    snakeParts.Add(new SnakePart() { Position = new Point(SnakeSquareSize * 5, SnakeSquareSize * 5) });
    gameTickTimer.Interval = TimeSpan.FromMilliseconds(SnakeStartSpeed);

    // Draw the snake again and some new food...
    DrawSnake();
    DrawSnakeFood();

    // Update status
    UpdateGameStatus();

    // Go!    
    gameTickTimer.IsEnabled = true;
}

Теперь, когда игра стартует, происходит следующее:

  • Поскольку это может быть не первый запуск игры, нам необходимо убедиться в том, что все возможные остатки предыдущей игры убраны с экрана: это касается как частей самой змейки, так и остатков пищи.
  • Мы также должны сбросить некоторые переменные к их начальным значениям, а именно: score, length, direction, и speed для таймера. Кроме того, добавим первоначальную голову змейки (далее метод MoveSnake() удлинит её автоматически).
  • После этого вызовем методы DrawSnake() и DrawSnakeFood() визуально отобразив начало новой игры.
  • Затем вызовем метод UpdateGameStatus().
  • И, наконец, мы готовы запустить gameTickTimer — он сразу же начнет отсчитывать временные интервалы, приведя, по сути, игру в движение.

Резюме

Если Вы пробрались через всю эту серию статей: поздравляю — вы только что создали свою первую WPF игру! Насладитесь же плодами Вашего тяжкого труда, запустите свой проект, нажмите Space и начните играть — даже в такой простой реализации Змейка является захватывающей игрой, доставляющей удовольствие!

This article has been fully translated into the following languages: Is your preferred language not on the list? Click here to help us translate this article into your language!