SL4 XNA Platformer Level Editor pour WP7 : conception de l’application Silverlight (2/4)
Nous avons vu lors de l’article précédent une introduction à l’application Silverlight permettant d’éditer les niveaux. Voyons maintenant un peu comment elle fut fabriquée et quelles sont les fonctionnalités intéressantes de Silverlight 4 à l’intérieur.
Note : vous trouverez le code final correspondant à l’application Silverlight démontrée dans l’article précédent à télécharger à la fin de cet article. J’ai essayé de documenter au maximum le code pour que ce soit complémentaire à cet article.
La genèse et les erreurs de conceptions initiales
J’ai toujours eu une idée précise du visuel et de l’expérience que je souhaitais pour l’éditeur de niveau. Des blocs à gauche, une surface principale de design au centre et la liste des niveaux et des actions possibles à droite. J’ai hésité un moment à laisser l’utilisateur faire du drag’n’drop des blocs de gauche vers le centre mais après quelques tests, je me suis aperçu que l’ergonomie d’un tel choix était affreux. Cependant, il me restait plusieurs problèmes à résoudre :
1 – Comment construire la zone de design du niveau ? A partir d’un contrôle existant ? Avec mon propre contrôle ?
2 – Comment générer des vignettes des niveaux chargés pour les afficher dans la ListBox ?
Je vais vous raconter l’histoire telle qu’elle s’est déroulée en espérant qu’elle vous serve pour éviter de faire les mêmes bêtises (bien évidemment, j’ai fait exprès de faire ces bêtises pour des besoins de pédagogie… ou pas !)
Le contrôle d’édition de niveaux
J’ai commencé à m’attaquer au 1er point en voulant faire mon fainéant et en voulant faire un rapide prototype. Je me suis dit que l’éditeur de niveau devait afficher X colonnes et Y lignes et qu’un contrôle DataGrid ressemblait assez à cela. La toute première version fut conçue en modifiant (voir en bidouillant) le contrôle DataGrid pour accueillir mes blocs de niveaux. Voici à quoi cela ressemblait :
Et voici le code qui correspond à ce premier test :
Pour en arriver là, j’ai simplement modifié la manière dont les lignes et cellules étaient sélectionnées à travers Expression Blend et fixé en dur le nombre de lignes et de colonnes. 8 colonnes et 16 lignes comme le requiert le format de niveau pour Windows Phone 7.
Cependant, rapidement, cela nous amenait à plusieurs conclusions :
- Comment s’adapter dynamiquement ou non à un autre type de niveau comme celui de la Xbox 360 ou PC nécessitant un nombre différent de lignes et colonnes ?
- La logique ligne par ligne du contrôle DataGrid n’était pas vraiment l’idéal pour mes niveaux. De plus, il embarque beaucoup de logique inutile pour mon éditeur de niveau.
Tentons de réfléchir 2 minutes et d’utiliser la bonne approche. Qu’avons nous besoin fonctionnellement parlant ? D’une collection d’éléments en mémoire (mes blocs ou cellules) avec un des éléments sélectionnable pour que l’on puisse le changer par un autre ainsi qu’un évènement indiquant que l’un des éléments a été changé. Un contrôle existe déjà gérant tout cela : il s’appelle le ListBox.
Oui mais… il n’affiche pas les éléments sous la forme d’une grille ? C’est pas grave, on va lui demander de changer de comportement.
Pour cela, utilisons alors magie du templating de Silverlight. Je suis donc parti d’un contrôle ListBox dans lequel j’ai changé l’ItemPanelTemplate pour finalement dessiner ses éléments d’une autre manière : sous la forme d’une grille avec le contrôle Grid.
Voici alors le XAML permettant de faire cela :
<ListBox x:Name="ListBoxDesignSurface"
ItemsSource="{Binding}"
Background="Transparent"
SelectionChanged="DrawSelectedBlock"
LayoutUpdated="ListBoxDesignSurface_LayoutUpdated">
<ListBox.ItemTemplate>
<DataTemplate>
<local:BlockCell />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<Grid ShowGridLines="False" Loaded="FirstFormatDesignSurface" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
On indique ici que chacun des éléments de la liste sera en fait désormais un BlockCell (grâce à la rédéfinition de l’ItemTemplte) et que les éléments ne s’afficheront plus les uns derrière les autres verticalement comme une ListBox classique mais sous la forme d’une grille. D’une manière générale, c’est ainsi qu’il faut essayer de raisonner en WPF ou Silverlight : quels sont mes besoins fonctionnels et n’y-a-t-il pas déjà un contrôle l’implémentant. Ensuite, on peut modifier son aspect visuel facilement en XAML.
Mon BlockCell est un simple UserControl contenant un contrôle Image qui affichera le bloc de niveau voulu. Il reste à faire une chose cependant : à distribuer les cellules de ma collection dans les différentes cases de ma grille.
Par rapport à WPF (comme le décrit Mitsu ici), cela est un peu plus compliqué à mettre en place en Silverlight pour 2 raisons:
1 – Nous n’avons pas accès à la propriété ItemContainerStyle, il va donc falloir retrouver l’ItemContainer via un helper
2 – On ne peut pas faire de binding sur les attached properties Column & Row
Pour palier à tout cela, lors de l’évènement Loaded du contrôle, on exécute le code suivant :
private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
var presenter = this.GetVisualParent<ListBoxItem>();
presenter.SetBinding(Grid.RowProperty, new Binding("Y"));
presenter.SetBinding(Grid.ColumnProperty, new Binding("X"));
Visibility = System.Windows.Visibility.Visible;
}
A travers la méthode GetVisualParent<T>(), on récupère l’instance du ListBoxItem (notre ItemContainer donc) contenant notre bloc et on positionne dessus le binding par code pour le numéro de colonne et de ligne souhaité.
Pour que ce code marche, il faut 2 choses:
1 – Bien préciser dans le CellViewModel servant de source au contrôle BlockCell d’un élément de niveau les propriétés X et Y:
public class CellViewModel : INotifyPropertyChanged
{
public int X { get; set; }<br> public int Y { get; set; }
private string imageUrl;
private char typeOfBlock;
public CellViewModel(char _varTypeOfBlock) : this(_varTypeOfBlock, 0,0)
{ }
public CellViewModel(char _varTypeOfBlock, int _X, int _Y)
{
#region SwitchImageTypeOfBlock
switch (_varTypeOfBlock)
{
case Constants.EMPTYBLOCK:
this.ImageUrl = @"Content/Tiles/Empty.png";
break;
case Constants.BLOCKA:
this.ImageUrl = @"Content/Tiles/BlockA0.png";
break;
...
}
#endregion
this.TypeOfBlock = _varTypeOfBlock;
this.X = _X;
this.Y = _Y;
this.IsAvailable = Visibility.Visible;
}
public string ImageUrl
{
get { ... }
set { ... }
}
public char TypeOfBlock
{
get { ... }
set { ... }
}
#region INotifyPropertyChanged Members
...
#endregion
}
2 – Fournir le helper permettant de parcourir l’arbre visuel pour retrouver l’élément de type ListBoxItem et lui appliquer le binding. Pour cela, Mitsu m’a aidé et en a même publié un article ici : https://blogs.msdn.com/b/mitsu/archive/2010/06/18/some-basic-sample-to-make-your-code-linq-ready.aspx . Voici le code dans mon cas :
public static class MyDependencyObjectExtensions
{
public static IEnumerable<DependencyObject> GetVisualParents(this DependencyObject source)
{
do
{
source = VisualTreeHelper.GetParent(source);
if (source != null)
yield return source;
} while (source != null);
}
public static T GetVisualParent<T>(this DependencyObject source) where T : class
{
return source.GetVisualParent<T>(0);
}
public static T GetVisualParent<T>(this DependencyObject source, int level) where T : class
{
return source.GetVisualParents().OfType<T>().Skip(level).FirstOrDefault();
}
}
A noter : je m’abonne à l’évènement Loaded du contrôle Grid utilisé au sein de l’ItemsPanelTemplate pour pouvoir en récupérer une instance et le formater avec le nombre de colonnes et de lignes que je souhaite de manière dynamique par code. Cela me permet alors d’être plus flexible si je dois éditer des niveaux venant d’une autre plateforme (Xbox, PC, etc.).
La génération des vignettes des niveaux chargés
Ma première idée fut d’utiliser un concept arrivé avec Silverlight 3 : les WriteableBitmap. On peut en effet faire une capture sous la forme d’une image Bitmap d’une partie de l’arbre visuel de Silverlight. Je me suis donc dis qu’il suffisait alors de faire un screenshot de mon niveau une fois chargé et d’afficher l’image correspondante dans la ListBox affichant l’ensemble des niveaux. Et puis lorsque l’utilisateur modifiera l’une des cellules du niveau, je ferais à nouveau un screenshot pour modifier la vignette dans la ListBox.
Malheureusement, il se trouve qu’il n’est pas très facile de savoir quand Silverlight a totalement fini d’afficher mon contrôle d’édition de niveau. L’évènement Loaded arrivant trop tôt, si je fais un screenshot à ce moment là, le contrôle n’a pas fini d’appliquer le templating complet de l’ItemsPanel… Il y a alors l’évènement LayoutUpdated qui permet d’en savoir un peu plus mais à nouveau, je n’ai pas réussi à savoir de manière très précise quand le dessin était terminé à part en comptant le nombre de passes dans cet évènement en mode debug puis en testant ce chiffre pour faire mon screenshot au bon moment…
Cet article résume un peu ce qui m’arrive : https://blogs.msdn.com/b/silverlight_sdk/archive/2008/10/24/loaded-event-timing-in-silverlight.aspx
Bref, cela donne ce genre de code dont je ne suis pas très fier :
private void ListBoxDesignSurface_LayoutUpdated(object sender, EventArgs e)
{
// If a new level has just been loaded, we need to detect
// the proper timing to generate the snapshop/thumbnail
if (NewLevelLoaded)
{
LayoutPass++;
// The good timing seems to be after 2 passes
if (LayoutPass == 2)
{
currentLoadedLevel.Thumbnail = new WriteableBitmap(SimulatedScreen, null);
levels.Add(currentLoadedLevel);
LayoutPass = 0;
NewLevelLoaded = false;
}
}
if (NewCellAdded)
{
if (currentLoadedLevel != null)
{
LayoutPass++;
// The good timing seems to be after 3 passes
if (LayoutPass == 3)
{
currentLoadedLevel.Thumbnail = new WriteableBitmap(SimulatedScreen, null);
LayoutPass = 0;
NewCellAdded = false;
}
}
else
{
NewCellAdded = false;
}
}
}
Le code correspondant à cette étape de mon travail est téléchargeable ici :
Problème :
1 – C’est quand même sacrément moche comme approche (bon ça, à la limite, je suis capable de vivre avec ;-))
2 – Lorsque je dépose plusieurs fichiers de niveaux en drag’n’drop, je me retrouve dans un contexte pseudo-multithreadé qui casse totalement cette bidouille de test du nombre de passes dans le LayoutUpdated. Je pouvais donc éventuellement limiter le drag’n’drop à un fichier maximum mais c’était quand même dommage d’en arriver là à cause d'une bidouille initiale.
La solution finale qui sera retenue pour résoudre tous les problèmes dont je vous parle depuis le début passera donc par la création d’un vrai contrôle qui sera réutilisé pour afficher le niveau en miniature. Du coup, avec le binding bidirectionnel, la mise à jour du contrôle principal au centre mettra automatiquement à jour le contrôle miniature dans la ListBox sans besoin de passer par du WriteableBitmap.
Le contrôle LevelEditor
Le contrôle LevelEditor encapsule tout simplement la logique présentée plus haut utilisant une ListBox comme base. Cela donne alors ce genre de code où l’on indique que l’on dérive d’une ListBox :
public class LevelEditor : ListBox
{
// Pointer to the Grid contained inside the ItemsPanel template of the ListBox
// used to display/design a level
private Grid itemsPanelGrid = null;
public int ColumnsCount
{
get { return (int)GetValue(ColumnsCountProperty); }
set { SetValue(ColumnsCountProperty, value); }
}
// Using a DependencyProperty as the backing store for ColumnsCountProperty. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ColumnsCountProperty =
DependencyProperty.Register("ColumnsCountProperty", typeof(int), typeof(LevelEditor), new PropertyMetadata(0, new PropertyChangedCallback(ColumnsCountChangedCallback)));
public static void ColumnsCountChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
(d as LevelEditor).OnColumnsCountChanged(e);
}
protected virtual void OnColumnsCountChanged(DependencyPropertyChangedEventArgs e)
{
UpdateGrid();
}
...
public void UpdateGrid()
{
if ((RowsCount != 0) && (ColumnsCount != 0) && (ItemsPanel != null))
{
// Searching the visual tree to find the Grid used for the ItemsPanel template
if (itemsPanelGrid == null)
itemsPanelGrid =
this.FindVisualChildren<Grid>()
.Where(g => g.Name == "itemsPanelGrid").FirstOrDefault();
FormatDesignSurface(itemsPanelGrid);
}
}
public LevelEditor()
{
base.Loaded += new RoutedEventHandler(LevelEditor_Loaded);
this.DefaultStyleKey = typeof(LevelEditor);
}
void LevelEditor_Loaded(object sender, RoutedEventArgs e)
{
UpdateGrid();
}
// Called to reformat the grid based on the values
// matching the targeted platform (Xbox/PC ou WP7)
public void FormatDesignSurface(Grid grid)
{
if (grid != null)
{
grid.ColumnDefinitions.Clear();
grid.RowDefinitions.Clear();
double CellsWidth = ActualWidth / ColumnsCount;
double CellsHeight = ActualHeight / RowsCount;
for (int x = 0; x < ColumnsCount; x++)
{
ColumnDefinition column = new ColumnDefinition();
column.Width = new System.Windows.GridLength(CellsWidth);
grid.ColumnDefinitions.Add(column);
}
for (int y = 0; y < RowsCount; y++)
{
RowDefinition row = new RowDefinition();
row.Height = new System.Windows.GridLength(CellsHeight);
grid.RowDefinitions.Add(row);
}
}
}
}
Il y a 2 Dependency Properties qui gère le nombre de colonnes et de lignes. Lorsque l’une des ses valeurs est changée par binding, on rappelle automatiquement la méthode FormatDesignSurface() qui s’occupe de reformatter la grille avec les bonnes valeurs. Reste un problème. Comment récupérer un pointeur vers l’instance de la grille utilisée dans l’ItemsPanelTemplate ? En effet, la modification des templates de la ListBox dans un vrai contrôle se fait par style :
<Style TargetType="local:LevelEditor">
<Setter Property="ItemTemplate">
<Setter.Value>
<DataTemplate>
<local:BlockCell />
</DataTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<Grid ShowGridLines="False" x:Name="itemsPanelGrid">
<Grid.Effect>
<DropShadowEffect ShadowDepth="10" />
</Grid.Effect>
</Grid>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBox">
<Grid>
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="2">
<ScrollViewer x:Name="ScrollViewer" BorderBrush="Transparent" BorderThickness="0" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}" TabNavigation="{TemplateBinding TabNavigation}">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Or, l’abonnement à un évènement Loaded dans un style n’aurait pas de sens. Il faut donc retrouver notre grille en parcourant à nouveau l’arbre visuel avec un autre helper FindVisualChildren() :
public static IEnumerable<T> FindVisualChildren<T>(this DependencyObject obj) where T : DependencyObject
{
// Search immediate children
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(obj); i++)
{
var child = VisualTreeHelper.GetChild(obj, i);
if (child != null)
{
if (child is T)
yield return child as T;
foreach (var childOfChild in FindVisualChildren<T>(child))
yield return childOfChild;
}
}
}
Et voilà le tour est joué ! On peut désormais utiliser ce contrôle au centre comme zone principale d’édition et le même contrôle pour afficher les miniatures. Pour afficher une miniature, le plus simple étant d’utiliser le contrôle Viewbox pour changer la taille facilement. Voici donc la définition de la ListBox affichant les niveaux chargés :
<!-- Listbox with the loaded level displaying them via thumbnails, binding source of the main editor -->
<ListBox Height="520" x:Name="lstLoadedLevels"
Width="125" Margin="0,20,35,0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
ScrollViewer.VerticalScrollBarVisibility="Visible"
SelectionChanged="lstLoadedLevels_SelectionChanged"
<ListBox.ItemTemplate>
<DataTemplate>
<Viewbox Height="160" Width="96">
<Grid>
<local:LevelEditor
IsEnabled="False"
Height="800" Width="480"
RowsCount="16" ColumnsCount="8"
ItemsSource="{Binding Cells}">
<local:LevelEditor.Background>
<ImageBrush ImageSource="Content/Backgrounds/SampleBackgroundLayer.png" />
</local:LevelEditor.Background>
</local:LevelEditor>
<TextBlock Text="{Binding Name}" FontSize="60" HorizontalAlignment="Right" VerticalAlignment="Top"/>
</Grid>
</Viewbox>
</DataTemplate>
</ListBox.ItemTemplate>
Et voici la définition de l’éditeur de niveau principal :
<!-- Main level editor on the center of the screen
bound to the current level selected in the right listbox
-->
<local:LevelEditor x:Name="MainLevelEditor"
Margin="10,0,0,0"
RowsCount="16" ColumnsCount="8"
Width="480" Height="800"
ItemsSource="{Binding SelectedItem.Cells, ElementName=lstLoadedLevels, Mode =TwoWay}"
SelectionChanged="MainLevelEditor_SelectionChanged">
<local:LevelEditor.Background>
<ImageBrush ImageSource="Content/Backgrounds/SampleBackgroundLayer.png" />
</local:LevelEditor.Background>
</local:LevelEditor>
Ce dernier est tout simplement “bindé” à l’élément actuellement sélectionné dans la ListBox comme le montre la ligne en gras.
Les fonctionnalités spécifiques à Silverlight 4
Comme vous l’avez vu dans la vidéo de présentation dans l’article précédent, l’application Silverlight supporte le drag’n’drop de fichiers *.txt contenant les niveaux, le bouton droit sur les éléments de la ListBox affichant les niveaux pour afficher un menu contextuel et l’utilisation des animations Fluid UI très simple à mettre en œuvre grâce à Expression Blend 4. Comme il existe déjà pas mal de ressources sur le Web à ce sujet, je n’entrerais pas dans les détails d’implémentation retenus dans cette application.
Anatomie de l’application finale
Quelques bonus cachés
Pour le fun, voici une petite optimisation d’écriture que m’a suggéré un fanatique Japonais de LINQ. Ma méthode initiale pour charger un nouveau niveau vide était celle-ci :
private void LoadEmptyLevelOldStyle()
{
ObservableCollection<CellViewModel> newCells = new ObservableCollection<CellViewModel>();
for (int y = 0; y < RowsNumber; y++)
{
for (int x = 0; x < ColumnsNumber; x++)
{
newCells.Add(new CellViewModel(Constants.EMPTYBLOCK, x, y));
}
}
currentLevel = new LevelViewModel(newCells, "new" + NewLevelIndex);
loadedLevels.Add(currentLevel);
NewLevelIndex++;
}
Mais quelle hérésie d’utiliser encore une double boucle for pour ce genre de chose en 2010 !?! Voici une version alternative plus agréable à l’œil :
private void LoadEmptyLevelModernStyle()
{
// Equivalent of a double for loop thanks to LINQ!
var qGenerateCells =
from y in Enumerable.Range(0, RowsNumber)
from x in Enumerable.Range(0, ColumnsNumber)
select new CellViewModel(Constants.EMPTYBLOCK, x, y);
ObservableCollection<CellViewModel> newCells = new ObservableCollection<CellViewModel>(qGenerateCells);
currentLevel = new LevelViewModel(newCells, "new" + NewLevelIndex);
loadedLevels.Add(currentLevel);
NewLevelIndex++;
}
La requête LINQ renvoie un arbre d’expressions stocké dans qGeneratedCells. Le constructeur d’ObservableCollection acceptant un type IEnumerable, on lui passe alors l’arbre d’expressions LINQ pour générer au final notre collection de cellules vides pour notre niveau. La requête LINQ est donc exécutée uniquement lors du new sur le type ObservableCollection.
Code final à télécharger
Voici la solution à télécharger contenant le code final :
Et voici quelques ressources bien utiles qui m’ont aidé :
- Mitsuru Furuta :-)
- Le blog de Mitsu: https://blogs.msdn.com/b/mitsu/archive/2010/06/18/some-basic-sample-to-make-your-code-linq-ready.aspx et un article de Mitsu sur WPF : https://msdn.microsoft.com/fr-fr/dd787685.aspx qu’il m’a aidé à adapter pour Silverlight 4.
- Icones WP7 : https://www.microsoft.com/downloads/details.aspx?FamilyID=369b20f7-9d30-4cff-8a1b-f80901b2da93&displaylang=en utilisés dans le menu contextuel via le bouton droit
Nous verrons dans l’article suivant les détails liés au stockage Azure.
David