This article is currently in the process of being translated into French (~99% done).
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!
Improving SnakeWPF: Adding a high score list
Dans l'article précédent, nous avons fait beaucoup d'améliorations visuelles à notre SnakeWPF. Dans cet article, j'aimerais ajouter une fonctionnalité très cool : Un tableau des meilleurs scores ! En plus de ça, j'aimerais rendre le jeu un peu plus ergonomique, en ajoutant un écran d'accueil. Je vais aussi remplacer la boîte de dialogue "Vous êtes mort", qui ne fait pas très jeu vidéo, par un écran.
Nous avons besoin d'un peu de code et de balises supplémentaires pour ça, mais commençons par la partie facile : le XAML !
XAML
La première chose que j'aimerais faire, c'est ajouter un tas de XAML à la fenêtre Snake. Il s'agira majoritairement de 4 nouveaux conteneurs (dans ce cas des contrôles Border), qui contiendront différents contrôles pour faire face à différentes situations :
- Un conteneur pour afficher un message de bienvenue quand le jeu est lancé, donnant des informations sur les touches à utiliser, etc.
- Un conteneur pour afficher le tableau des meilleurs scores
- Un conteneur à afficher quand l'utilisateur a battu un des meilleurs scores, incluant une TextBox pour saisir un nom
- Un conteneur à afficher quand l'utilisateur meurt, mais ne figure pas dans le tableau des meilleurs scores (pour remplacer la MessageBox agaçante que nous utilisions précédemment)
Nous allons ajouter ces conteneurs au Canvas GameArea puis simplement les cacher quand nous n'en avons pas besoin. Comme mentionné, chaque conteneur aura un rôle différent et inclura quelques balises, mais nous utiliserons seulement des contrôles WPF qui ont déjà été abordés dans ce tutoriel.
Message de Bienvenue
Ajoutez ce morceau de XAML dans le contrôle Canvas GameArea :
<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>
Ça expliquera rapidement à l'utilisateur en quoi consiste le jeu, comment on contrôle le Snake et comment lancer le partie. Le Border, qui contient tout le contenu, est visible initialement, c'est donc la première chose que l'utilisateur voit quand le jeu est lancé. En bas de l'écran, j'ai ajouté un Button pour afficher le tableau des meilleurs scores (qu'on va ajouter dans une minute). La gestion de l'événement Click sera implémentée dans le Code-behind plus tard.
Tableau des meilleurs scores
C'est plus compliqué maintenant, car je souhaite le réaliser à la manière des WPF et utiliser le data-binding pour afficher la liste des meilleurs score au lieu de construire et mettre à jour la liste manuellement. Pas de quoi s'inquiéter, je vais tout expliquer à mesure que nous avançons. Premièrement, ajoutez ce block de code XAML à l'intérieur de la Canvas GameArea, comme nous l'avons fait précédement, la Canvas va, comme mentionnée, contenir tout nos contrôles Border, chacuns d'entre eux offrant leur propre fonctionnalité pour notre jeu.
<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>
Voyez comme ce Border ne s'affiche initialement pas (Visibility = Collapsed). Nous utilisons un ItemsControl (Nous en avons parlé précédement dans ce tutoriel), avec un ItemsSource customisé appelé HighScoreListViewSource. Nous allons utliser une CollectionViewSource pour être sûr que la collection à laquelle nous nous lions est toujours triée. Nous devons définir une ressource dans la Fenêtre XAML, en tant qu'enfant de l'étiquette (tag) de la Fenêtre, vous devriez ajouter ce code de markup dans la déclaration de la Fenêtre, votre déclaration de Fenêtre ressemblant à ça:
<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>
................
Remarquez que j'ai furtivement ajouté une nouvelle référence: La xmlns:scm, utilisée pour accéder au type SortDescription. J'ai aussi ajouté la propriété x:Name et défini avec window, de telle manière que l'on puisse référencer les membres définis dans la classe MainWindow dans le code derrière celle-ci.
Dans Window.Resources, j'ai ajouté une nouvelle ressource CollectionViewSource. Elle utilise le binding pour se lier à une propriété nommée HighscoreList, que nous allons définir derrière dans le code. Remarquez aussi que j'ajoute une SortDescription à celle-ci, spécifiant à la liste qu'elle doit être triée par ordre descendant par une propriété nommée Score, signifiant que le score le plus élevé sera affiché en premier et ainsi desuite.
Derrière dans le code, nous devons définir une propriété nommée HighscoreList, sur laquelle ItemsSource dépend, mais nous nous en occuperons après avoir ajouté le dernier XAML.
Nouveau meilleur score
Quand l'utilisateur bat un meilleur score existant, nous afficherons un beau message à ce propos. Le XAML ressemble à ça, une fois de plus, it doit être ajouté à l'intérieur du 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>
Tout est très simple avec un peu de texte, une TextBox pour entrer le nom et un bouton à cliquer pour l'ajouter à la liste - nous allons définir l'event handler BtnAddToHighscoreList_Click plus tard.
"Oh non, vous êtes mort !"
La dernière partie est l'écran avec "Oh non, vous êtes mort et vous ne faites pas partie de la liste des meilleurs score", que nous allons utiliser pour remplacer l'ennuyeuse MessageBox qui faisait exactement la même chose avant. Le XAML ressemble à ça:
<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>
Il informe l'utilisateur de la malheureuse tournure des évènements, affiche le score final et informe le joueur sur la manière de relancer une partie - assez simple !
Code en Arrière-Plan
Avec le XAML en place, nous sommes fins prêt à implémenter le code en arrière-plan ! Premièrement, nous devont implémenter les event handlers définis dans le XAML. Voici celui pour le bouton "Afficher la liste des meilleurs scores":
private void BtnShowHighscoreList_Click(object sender, RoutedEventArgs e)
{
bdrWelcomeMessage.Visibility = Visibility.Collapsed;
bdrHighscoreList.Visibility = Visibility.Visible;
}
Comme vous pouvez le voir, c'est assez simple - quand le bouton est cliqué, nous cachons le message de bienvenue et nous affichons la liste des meilleurs scores - nous allons l'ajouter maintenant.
Implémenter la liste des meilleurs scores
L'autre event handler que nous avons concerne le fait d'ajouter une nouvelle entrée dans la liste des meilleurs scores, mais pour ça, nous devons d'autres choses à ajouter - tout d'abord la propriété qui détient les entrées des meilleurs scores:
public ObservableCollection<SnakeHighscore> HighscoreList
{
get; set;
} = new ObservableCollection<SnakeHighscore>();
Comme vous pouvez le voir, c'est une ObservableCollection, qui détient le type SnakeHighscore. D'abord, faites en sorte d'inclure le namespace contenant le type ObservableCollection:
using System.Collections.ObjectModel;
Ensuite implémentez la classe SnakeHighscore
public class SnakeHighscore
{
public string PlayerName { get; set; }
public int Score { get; set; }
}
Comme vous pouvez le voir, c'est une classe assez simple - elle sert en tant que conteneur pour le nom et le score du joueur qui est entré dans la liste des meilleurs scores.
Charger / Sauvegarder la liste de meilleurs scores
Nous avons aussi besoin de coder le chargement et la sauvegarde de la liste - la méthode Save sera appelée quand une nouvelle entrée sera ajoutée à la liste, tandis que la méthode Load sera appelée au lancement du jeu. Je vais utiliser un fichier XML basic pour contenir la liste, ce qui nous permettra d'utiliser la classe XMLSerializer native pour automatiquement charger et sauvegarder la liste.
Il existe de BEAUCOUP de façons de charger/sauvegarder des données et plusieurs autres formats pertinents tels que JSON ou encore un simple fichier texte. Mais je souhaitais que cette partie contienne le moins de ligne de codes possible puisque ce n'est pas pertinent pour un tutoriel WPF. De plus, l'approche XmlSerializer rend le code plutôt flexible - vous pouvez facilement ajouter de nouvelles propriétés à la classe SnakeHighScore et ces données seront automatiquement persistantes. Voici la méthode 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);
}
}
}
Vous devez inclure des namespaces supplémentaires pour cela :
using System.IO;
using System.Xml.Serialization;
Fait attention d'appeler la méthode LoadHighscoreList(), par exemple dans le constructeur de Window :
public SnakeWPFSample()
{
InitializeComponent();
gameTickTimer.Tick += GameTickTimer_Tick;
LoadHighscoreList();
}
Ensuite, nous implémentons la méthode 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);
}
}
La méthode Save est la plus pertinente à appeler quand nous ajoutons une nouvelle entrée - cela se passe dans l'event handler BtnAddToHighscoreList_Click(), qui devrait ressembler à ceci :
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;
}
C'est assez simple : nous essayons de déterminer si la nouvelle entrée devrait être ajoutée au haut de la liste (un nouveau record !) ou si sa place est plus bas dans la liste. Une fois que nous avons le nouvel index, nous insérons une nouvelle instance de la classe SnakeHighscore, utilisant le score actuel et le nom entré par le joueur. Ensuite, nous retirons les entrées indésirables du bas de la liste, si la liste a soudainement plus d'éléments que nous le souhaiterions (MaxHighscoreListEntryCount). Enfin, nous sauvegardons la liste (SaveHighscoreList() et cachons le conteneur bdrNewHighscore, en échangeant la vue avec le conteneur bdrHighscoreList.
Néanmoins il reste quelques petites choses à faire. Tout d'abord, ces nouveaux affichages (message de fin de partie, liste des meilleurs scores, etc.) doivent être cachés à chaque fois qu'une nouvelle partie commence. Donc, le début de la méthode StartNewGame(), que nous avons implémenté dans l'article précédent, devrait ressembler à ceci :
private void StartNewGame()
{
bdrWelcomeMessage.Visibility = Visibility.Collapsed;
bdrHighscoreList.Visibility = Visibility.Collapsed;
bdrEndOfGame.Visibility = Visibility.Collapsed;
........
La seconde chose que nous devons faire est de modifier la méthode EndGame(). Au lieu de simplement afficher le MessageBox, nous devons vérifier si l'utilisateur entre dans le top des meilleurs scores ou non et afficher le bon conteneur de message :
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;
}
La méthode vérifie s'il reste des emplacements vides dans la liste des meilleurs scores (nous définissons un maximum de 5 entrées) ou si l'utilisateur vient de battre un score existant - si c'est le cas, nous permettons à l'utilisateur d'ajouter leur nom en affichant le conteneur bdrNewHighscore. Si aucun nouveau record n'a été accompli, nous affichons à la place le conteneur bdrEndOfGame. Assurez-vous de définir la constante MaxHighscoreListEntryCount :
const int MaxHighscoreListEntryCount = 5;
J'ai aussi modifié la méthode ContentRendered() - Maintenant qu'on a un écran de bienvenue d'une bonne apparence, on ne veut pas que le jeu démarres automatiquement. Au lieu de cela, nous recommandons à l'utilisateur d'appuyer sur Espace pour commencer la partie ou bien de clicker sur un bouton pour voir la liste des meilleurs scores avant le début de celle-ci. Il n'y a plus qu'à enlever (ou commenter) l'appel à StartNewGame():
private void Window_ContentRendered(object sender, EventArgs e)
{
DrawGameArea();
//StartNewGame();
}
Avec tout cela en place, lancez le jeu et faites de votre mieux - aussitôt que le jeu finira, vous devriez normalement avoir décroché une place dans votre toute nouvelle liste SnakeWPF des meilleurs scores !
Résumé
Dans cet article, nous avons fait BEAUCOUP d'amélioration à notre projet SnakeWPF. Le plus visible étant bien sûr la liste des meilleurs scores, ce qui demande pas mal de code/markup en plus, mais cela vaut totalement le coup ! En plus, nous avons des améliorations d'utilisations tout en rendant, une fois de plus, l'esthétique de notre projet plus proche d'un véritable jeu.