This article has been localized into Russian by the community.
A special thanks goes out to user #4212 for the Russian translation of this article: Alexander Komissarov
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!
Улучшаем SnakeWPF: добавление списка рекордов
В предыдущей статье мы внесли множество визуальных улучшений в наш SnakeWPF. Сейчас я хотел бы добавить крутую функцию A high score list! (Список рекордов). Кроме того, я бы хотел сделать игру более удобной путем добавления экрана приветствия. Также я заменю непривычное для игр всплывающее окно "You died" ("Вы умерли") на дополнительный игровой экран.
Нам понадобится достаточно много дополнительного кода, чтобы это сделать, но давайте начнем с простого - с XAML!
XAML
Первым делом я добавлю большой кусок XAML в окно Snake. Код в основном состоит из 4 новых контейнеров (в данном случае это Border controls), которые будут содержать набор дочерних элементов для поддержки различных сценариев:
- Первый контейнер будет показывать сообщение "Добропожаловать" при начале игры, информировать о кнопках управления и т.д.
- Следующий контейнер будет отображать список рекордов
- Еще один контейнер проинформирует о том, что превзойден один из прежних рекордов, включая TextBox для ввода имени игрока
- И последний контейнер укажет на гибель игрока без достижения рекорда (и заменит скучный MessageBox, который мы использовали ранее)
Мы добавим эти контейнеры в GameArea Canvas и затем просто спрячем их до тех пор пока они не понадобятся. Как уже говорилось, каждый контейнер будет служить определенной цели и включать немного markup, но мы будем использовать только те элементы управления, которые уже обсуждались ранее.
Приветственное сообщение
Добавьте этот код XAML внутрь элемента управления GameArea Canvas:
<Border BorderBrush="Silver" BorderThickness="2" Width="300" Height="300" Canvas.Left="50" Canvas.Top="50" Name="bdrWelcomeMessage" Panel.ZIndex="1">
<StackPanel Orientation="Vertical" Background="AliceBlue">
<TextBlock FontWeight="Bold" FontSize="50" HorizontalAlignment="Center" Margin="0,20,0,0">SnakeWPF</TextBlock>
<TextBlock TextWrapping="Wrap" Margin="20" FontSize="16">Use the Arrow keys to control the green snake. Make it eat the red apples, but be sure not to crash into the walls or the tail of the snake!</TextBlock>
<TextBlock FontWeight="Bold" HorizontalAlignment="Center" FontSize="24" Foreground="Maroon">Press SPACE to start!</TextBlock>
<Button Margin="20" Name="btnShowHighscoreList" Click="BtnShowHighscoreList_Click" HorizontalAlignment="Center" Padding="10,3">Show High Score List...</Button>
</StackPanel>
</Border>
Здесь коротко говорится, что это за игра, как управлять Змейкой и как начать игру. Border, где находится текст, видим с самого начала, это будет первое, что увидит игрок, когда игра начнется. Внизу экрана я добавил кнопку для показа списка рекордов (который мы добавим через минуту). Управление кликом (Click event handler) мы добавим позже в Code-behind.
Список рекордов
Ну а теперь займемся более сложной частью, потому что я хочу это сделать WPF-способом и использовать привязку данных (data binding) для отображения Списка рекордов вместо создания и обновления этого списка вручную. Но не волнуйтесь, я все объясню по пути. Первым делом, добавьте этот кусок XAML внутрь GameArea Canvas, точно так как мы делали это ранее - Canvas, как упоминалось ранее, будет содержать все наши элементы управления Border, каждый из которых имеет свою собственную функциональность для нашей игры:
<Border BorderBrush="Silver" BorderThickness="2" Width="300" Height="300" Canvas.Left="50" Canvas.Top="50" Name="bdrHighscoreList" Panel.ZIndex="1" Visibility="Collapsed">
<StackPanel Orientation="Vertical" Background="AliceBlue">
<Border BorderThickness="0,0,0,2" BorderBrush="Silver" Margin="0,10">
<TextBlock HorizontalAlignment="Center" FontSize="34" FontWeight="Bold">High Score List</TextBlock>
</Border>
<ItemsControl ItemsSource="{Binding Source={StaticResource HighScoreListViewSource}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="7">
<TextBlock Text="{Binding PlayerName}" DockPanel.Dock="Left" FontSize="22"></TextBlock>
<TextBlock Text="{Binding Score}" DockPanel.Dock="Right" FontSize="22" HorizontalAlignment="Right"></TextBlock>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
Заметьте что первоначально Border не отображается (Visibility = Collapsed). Мы используем ItemsControl (о котором говорилось ранее), с пользовательским ItemsSource которое мы назвали HighScoreListViewSource. Мы будем использовать CollectionViewSource для того чтобы убедиться, что коллекция (collection), к которой мы привязываемся, была правильно отсортирована. Нам нужно определить этот ресурс в Window XAML, поэтому дабавьте этот фрагмент разметки как дочерний элемент тэга Window, чтобы Window выглядел вот таким образом:
<Window x:Class="WpfTutorialSamples.Games.SnakeWPFSample"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfTutorialSamples.Games"
mc:Ignorable="d"
x:Name="window"
xmlns:scm="clr-namespace:System.ComponentModel;assembly=WindowsBase"
Title="SnakeWPF - Score: 0" SizeToContent="WidthAndHeight" ContentRendered="Window_ContentRendered" KeyUp="Window_KeyUp"
ResizeMode="NoResize" WindowStyle="None" Background="Black" MouseDown="Window_MouseDown">
<Window.Resources>
<CollectionViewSource Source="{Binding ElementName=window, Path=HighscoreList}" x:Key="HighScoreListViewSource">
<CollectionViewSource.SortDescriptions>
<scm:SortDescription Direction="Descending" PropertyName="Score" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</Window.Resources>
................
Обратите внимание, что я добавил новую ссылку: xmlns:scm используемую для доступа к типу SortDescription. Также я добавил свойство x:Name и установил исходное значение window, чтобы мы могли ссылаться на составные определенные в классе MainWindow в Code-behind.
В Window.Resources я добавил CollectionViewSource, который использует привязку для присоединения к свойству HighscoreList, которое мы определим ниже. Также обратите внимание, что я добавляю SortDescription, указывая что список должен быть отсортирован по убыванию по свойству Score. Это означает, что первым будет показан наивысший счет.
В коде ниже мы должны определить свойство HighscoreList, от которого зависит ItemsSource, но мы вернемся к этому после того как мы закончим добавление последнего XAML.
Новый рекорд
Когда игрок побьет предыдущий рекорд мы покажем специальное сообщение с информацие об этом. XAML будет выглядеть вот так, и еще раз, этот код должен быть размещен внутри GameArea Canvas:
<Border BorderBrush="Silver" BorderThickness="2" Width="300" Height="300" Canvas.Left="50" Canvas.Top="50" Name="bdrNewHighscore" Panel.ZIndex="1" Visibility="Collapsed">
<StackPanel Orientation="Vertical" Background="AliceBlue">
<TextBlock HorizontalAlignment="Center" FontSize="34" FontWeight="Bold" Margin="20">New Highscore!</TextBlock>
<TextBlock HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="16">
Congratulations - you made it into the SnakeWPF highscore list! Please enter your name below...
</TextBlock>
<TextBox Name="txtPlayerName" FontSize="28" FontWeight="Bold" MaxLength="8" Margin="20" HorizontalContentAlignment="Center"></TextBox>
<Button Name="btnAddToHighscoreList" FontSize="16" HorizontalAlignment="Center" Click="BtnAddToHighscoreList_Click" Padding="10,5">Add to highscore list</Button>
</StackPanel>
</Border>
Все делается просто, TextBox для ввода имени и кнопка Button, которую можно нажать и таким образом добавить имя в список. Позже мы добавим обработчик события (event handler) BtnAddToHighscoreList_Click.
"О нет - вы умерли!"
Последняя часть это экран "О нет, вы умерли и не попали в список рекордов", который мы используем вместо скучного MessageBox, который исполнял ту же функцию раньше. XAML код выглядит вот таким образом:
<Border BorderBrush="Silver" BorderThickness="2" Width="300" Height="300" Canvas.Left="50" Canvas.Top="50" Name="bdrEndOfGame" Panel.ZIndex="1" Visibility="Collapsed">
<StackPanel Orientation="Vertical" Background="AliceBlue">
<TextBlock HorizontalAlignment="Center" FontSize="40" FontWeight="Bold" Margin="0,20,0,0">Oh no!</TextBlock>
<TextBlock HorizontalAlignment="Center" FontSize="26" FontWeight="Bold">...you died!</TextBlock>
<TextBlock Margin="20" TextAlignment="Center" FontSize="16">Your score: </TextBlock>
<TextBlock Name="tbFinalScore" TextAlignment="Center" FontSize="48" FontWeight="Bold" Foreground="Maroon">0</TextBlock>
<TextBlock TextAlignment="Center" FontSize="16" Margin="20">Press SPACE to start a new game!</TextBlock>
</StackPanel>
</Border>
Этот экран сообщает о неудаче, показывает финальный счет и указывает, как начать игру заново - довольно просто!
C# код
Теперь, когда часть XAML полностью готова, мы можем приступить к написанию соответствующего C# кода. Первым делом реализуем обработчики событий (event handlers), которые мы используем в XAML. Вот код для кнопки "Показать список рекордов" ("Show high score list"):
private void BtnShowHighscoreList_Click(object sender, RoutedEventArgs e)
{
bdrWelcomeMessage.Visibility = Visibility.Collapsed;
bdrHighscoreList.Visibility = Visibility.Visible;
}
Все достаточно просто - при нажатии кнопки мы скрываем сообщение "Добропожаловать" и показываем "Список рекордов" - мы сейчас это добавим.
Создаем список рекордов
Другой обработчик событий (event handler) нужен для того, чтобы добавлять новую запись в Список рекордов, но для этого нам нужно сделать пару добавлений. Первым делом, собственно свойство, которое будет сохранять рекорды:
public ObservableCollection<SnakeHighscore> HighscoreList
{
get; set;
} = new ObservableCollection<SnakeHighscore>();
Как видно, это ObservableCollection, содержит тип SnakeHighscore. Первым делом, включите пространство имен (namespace) содержащее тип ObservableCollection:
using System.Collections.ObjectModel;
Затем напишем класс SnakeHighscore:
public class SnakeHighscore
{
public string PlayerName { get; set; }
public int Score { get; set; }
}
Достаточно простой класс, как вы видите - он служит в качестве контейнера для имени и счета игрока, который достиг списка рекордов.
Загружаем/сохраняем Список рекордов
Нам также нужно написать немного кода, чтобы загружать и сохранять список - метод Save будет вызываться когда новая запись будет записана в список, а метод Load будем вызывать в начале игрыю Я буду использовать простой XML файл для того, чтобы хранить данные для списка, это позволит нам использовать стандартный класс XmlSerializer, с помощью которого легко автоматически загружать и сохранять список.
Существует множество возможностей для загрузки/сохранения данных, и несколько подходящих форматов например JSON или простой текстовый файл (plain text file), но я хотел написать эту часть используя наименьшее количество кода, т.к. это не так важно для изучения WPF. Также, подход XmlSerializer делает код очень гибким - вы легко можете добавить новые свойства в класс SnakeHighScore и они будут автоматически сохранены. Вот мой метод LoadHighscoreList():
private void LoadHighscoreList()
{
if(File.Exists("snake_highscorelist.xml"))
{
XmlSerializer serializer = new XmlSerializer(typeof(List<SnakeHighscore>));
using(Stream reader = new FileStream("snake_highscorelist.xml", FileMode.Open))
{
List<SnakeHighscore> tempList = (List<SnakeHighscore>)serializer.Deserialize(reader);
this.HighscoreList.Clear();
foreach(var item in tempList.OrderByDescending(x => x.Score))
this.HighscoreList.Add(item);
}
}
}
Для этого нужно включить в код пару новых пространств имен (namespaces):
using System.IO;
using System.Xml.Serialization;
Не забудьте вызвать метод LoadHighscoreList(), например в конструкторе Window:
public SnakeWPFSample()
{
InitializeComponent();
gameTickTimer.Tick += GameTickTimer_Tick;
LoadHighscoreList();
}
Теперь создадим метод SaveHighscoreList():
private void SaveHighscoreList()
{
XmlSerializer serializer = new XmlSerializer(typeof(ObservableCollection<SnakeHighscore>));
using(Stream writer = new FileStream("snake_highscorelist.xml", FileMode.Create))
{
serializer.Serialize(writer, this.HighscoreList);
}
}
Метод Save следует вызывать когда мы добавляем новую запись - это происходит в обработчике событий (event handler) BtnAddToHighscoreList_Click(), который должен выглядеть вот так:
private void BtnAddToHighscoreList_Click(object sender, RoutedEventArgs e)
{
int newIndex = 0;
// Where should the new entry be inserted?
if((this.HighscoreList.Count > 0) && (currentScore < this.HighscoreList.Max(x => x.Score)))
{
SnakeHighscore justAbove = this.HighscoreList.OrderByDescending(x => x.Score).First(x => x.Score >= currentScore);
if(justAbove != null)
newIndex = this.HighscoreList.IndexOf(justAbove) + 1;
}
// Create & insert the new entry
this.HighscoreList.Insert(newIndex, new SnakeHighscore()
{
PlayerName = txtPlayerName.Text,
Score = currentScore
});
// Make sure that the amount of entries does not exceed the maximum
while(this.HighscoreList.Count > MaxHighscoreListEntryCount)
this.HighscoreList.RemoveAt(MaxHighscoreListEntryCount);
SaveHighscoreList();
bdrNewHighscore.Visibility = Visibility.Collapsed;
bdrHighscoreList.Visibility = Visibility.Visible;
}
Это достаточно просто: Мы должны решить нужно ли добавлять новую запись в топ списка (как новое лучшее достижение!) или ее стоит разместить ниже. Как только мы получаем новый индекс, мы создаем новый экземпляр класса SnakeHighscore, используя текущий счет и имя, введенное игроком. Затем мы удаляем все ненужные записи снизу списка, если список становится длиннее, чем мы хотим (MaxHighscoreListEntryCount). Затем мы сохраняем список (SaveHighscoreList()) и скрываем контейнер bdrNewHighscore, делая фокус на контейнер bdrHighscoreList.
Однако, остается еще кое-что, что нужно сдеать. Первым делом, эти новые экраны ("О нет, вы умерли" и "Список рекордов" и проч.) должны быть скрыты каждый раз когда начинается новая игра. Поэтому верхня часть метода StartNewGame(), который мы написали в прошлый раз, теперь должен выглядеть следующим образом:
private void StartNewGame()
{
bdrWelcomeMessage.Visibility = Visibility.Collapsed;
bdrHighscoreList.Visibility = Visibility.Collapsed;
bdrEndOfGame.Visibility = Visibility.Collapsed;
........
Следующее, что мы должны сделать это изменить метод EndGame(). Вместо того, чтобы просто показывать MessageBox, мы должны проверить набрал ли игрок достаточно очков, чтобы быть добавленным в Список рекордов или нет. И затем показать соответствующий контейнер:
private void EndGame()
{
bool isNewHighscore = false;
if(currentScore > 0)
{
int lowestHighscore = (this.HighscoreList.Count > 0 ? this.HighscoreList.Min(x => x.Score) : 0);
if((currentScore > lowestHighscore) || (this.HighscoreList.Count < MaxHighscoreListEntryCount))
{
bdrNewHighscore.Visibility = Visibility.Visible;
txtPlayerName.Focus();
isNewHighscore = true;
}
}
if(!isNewHighscore)
{
tbFinalScore.Text = currentScore.ToString();
bdrEndOfGame.Visibility = Visibility.Visible;
}
gameTickTimer.IsEnabled = false;
}
Главное, что делает этот метод - проверяет есть ли еще свободные места в Списке рекордов (мы определили, что максимально будет 5 записей) или возможно игрок побил один из существующих рекордов. Если так мы разрешаем игроку добавить свое имя с помощью контейнера bdrNewHighscore. Если нового рекорда нет вместо этого мы показываем контейнер bdrEndOfGame. Убедитесь, что константа MaxHighscoreListEntryCount определена.
const int MaxHighscoreListEntryCount = 5;
Я также изменил метод ContentRendered() - теперь, когда у нас есть красивый экран приветствия, мы не хотим, чтобы игра просто запускалась автоматически. Вместо этого мы призываем пользователя нажать Пробел, чтобы начать игру, или нажать на кнопку, чтобы увидеть список рекордов перед началом игры, поэтому мы просто удаляем (или комментируем) вызов StartNewGame().
private void Window_ContentRendered(object sender, EventArgs e)
{
DrawGameArea();
//StartNewGame();
}
Теперь, закончив все это, начните игру заново, выложитесь по максимуму - и как только игра закончится надеюсь увидите себя в новом списке рекордов Snake WPF!
Обобщая
В этой части мы внесли МНОГО изменений в разработку нашей игры SnakeWPF. Наиболее очевидное это конечно Список рекордов, который потребовал достаточно много добавления кода и XAML, но это того стоило! Вдобавок мы внесли несколько хороших улучшений юзабилити, что делает наш проект все более похожим на настоящую игру.