Loupe

[C#] Créer un jeu avec FMOD – Spectrogramme et plateau de jeu

 

Bonjour à tous et bienvenue dans cette série d’articles consacrées à FMOD et son utilisation dans une application Windows Store.

Le but de ces articles sera de créer un petit jeu en utilisant quelques fonctionnalités de la librairie FMOD. Nous utiliserons pour cela le langage C#/XAML avec MVVM Light.

Partie 1 - Introduction et mise en place

Partie 2 – Bases du jeu et analyse spectrale du signal

Partie 3 – Spectrogramme et plateau de jeu

Représentation graphique du spectre

Pour bien comprendre à quoi correspond l’analyse spectrale que nous avons fait dans le précédent article, nous allons en faire une représentation graphique (spectrogramme) qui mettra un peu d’ambiance dans notre jeu. Cette représentation graphique peut se retrouver sur la plupart des lecteurs comme Winamp par exemple.

On représentera chaque plage de fréquence par une barre horizontale, la longueur dépendra de la puissance du signal. On fixe la longueur maximum à 300px.

MainViewModel.cs
public ObservableCollection<float> SpectrumLeftSize { get; set; }
public ObservableCollection<float> SpectrumRightSize { get; set; }

public MainViewModel()
{
    SpectrumLeftSize = new ObservableCollection<float>();
    SpectrumRightSize = new ObservableCollection<float>();
    ...
}

public async void Analyse()
{
    while (IsPlaying)
    {
        ...

        SpectrumLeftSize.Clear();
        SpectrumRightSize.Clear();

        for (int i = 0; i < SPECTRUM_SIZE; i++)
        {
            LeftSpectrum[i] = LeftSpectrum[i] / maxSpectrum;
            RightSpectrum[i] = RightSpectrum[i] / maxSpectrum;
            TotalSpectrum[i] = TotalSpectrum[i] / maxSpectrum;

            // Set size
            SpectrumLeftSize.Add(LeftSpectrum[i] * 300);
            SpectrumRightSize.Add(RightSpectrum[i] * 300);
        }

       ...
    }
}
MainPage.xaml
<Border BorderThickness="2"
        BorderBrush="Orange"
        Background="Gray"
        CornerRadius="0,0,10,10"
        Margin="20,-2,0,0">
           ...
</Border>

<Grid Grid.Row="1"
        Margin="10"
        VerticalAlignment="Stretch">
    <Grid.ColumnDefinitions>
        <ColumnDefinition />
        <ColumnDefinition />
    </Grid.ColumnDefinitions>

    <ItemsControl ItemsSource="{Binding SpectrumRightSize}"
                    HorizontalAlignment="Right"
                    HorizontalContentAlignment="Right"
                    VerticalAlignment="Stretch">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Orientation="Vertical" />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Border Height="1"
                        Margin="0,0.5"
                        Background="#660000FF"
                        BorderThickness="0"
                        Width="{Binding}"
                        HorizontalAlignment="Right" />
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>

    <ItemsControl Grid.Column="1"
                    ItemsSource="{Binding SpectrumRightSize}"
                    HorizontalAlignment="Left"
                    HorizontalContentAlignment="Left">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Orientation="Vertical" />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Border Height="1"
                        Margin="0,0.5"
                        Background="#660000FF"
                        BorderThickness="0"
                        Width="{Binding}"
                        HorizontalAlignment="Left" />
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</Grid>

Ce qui va donner le résultat suivant :

spectre_graphique

 

 

Définition et création des monstres

Nous allons créer la classe Monster qui servira à savoir si l’on doit faire apparaitre un monstre dans notre grille. Un seul monstre pourra donc potentiellement s’afficher 6 fois (Basse côté gauche, basse coté droit, médium côté gauche, … ).

Monster.cs
public class Monster
{
    public bool LeftLow { get; set; }
    public bool LeftMedium { get; set; }
    public bool LeftBass { get; set; }

    public bool RightLow { get; set; }
    public bool RightMedium { get; set; }
    public bool RightBass { get; set; }
}

Rien de très compliqué, nous avons 6 propriétés qui correspondent à chaque endroits où nos monstres pourront sortir :) Lorsqu’une propriété sera à true, un monstre sera créé dans la grille.

Grille de jeu

Notre plateau de jeu sera composée principalement d’un DependencyProperty contenant une collection de montre. Lors de chaque ajout, nous placerons les monstres sur la grille et jouerons une animation.

GameGrid.xaml
<UserControl x:Class="FMODGame.Views.GameGrid"
             ...>

    <Grid Margin="30"
          x:Name="game"
          Canvas.ZIndex="1">
    </Grid>
</UserControl>
GameGrid.xaml.cs
public sealed partial class GameGrid : UserControl
{
    public GameGrid()
    {
        this.InitializeComponent();
        InitializeGrid();
    }

    private void InitializeGrid()
    {
        // Grid with 3 rows and 2 columns
        game.RowDefinitions.Add(new RowDefinition());
        game.RowDefinitions.Add(new RowDefinition());
        game.RowDefinitions.Add(new RowDefinition());
        game.ColumnDefinitions.Add(new ColumnDefinition());
        game.ColumnDefinitions.Add(new ColumnDefinition());
    }

    public ObservableCollection<MonsterViewModel> Monsters
    {
        get { return (ObservableCollection<MonsterViewModel>)GetValue(MonstersProperty); }
        set { SetValue(MonstersProperty, value); }
    }

    // Using a DependencyProperty as the backing store for monsters.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty MonstersProperty =
        DependencyProperty.Register("Monsters", typeof(ObservableCollection<MonsterViewModel>),
                         typeof(GameGrid), new PropertyMetadata(null, PropertyChangedCallback)); 

    private static void PropertyChangedCallback(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var me = sender as GameGrid;
        if (me == null)
            return;

        var old = e.OldValue as ObservableCollection<MonsterViewModel>;

        if (old != null)
            old.CollectionChanged -= me.OnWorkCollectionChanged;

        var n = e.NewValue as ObservableCollection<MonsterViewModel>;

        if (n != null)
            n.CollectionChanged += me.OnWorkCollectionChanged;
    }

    private void OnWorkCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Reset)
        {
            // Clear and update entire collection
            game.Children.Clear();
        }

        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            // Add item in collection
            foreach (MonsterViewModel item in e.NewItems)
            {
                if (item.CurrentMonster.LeftBass)
                {
                    var monster = CreateMonster(item, 0, 0);
                    game.Children.Add(monster);

                    object sbTemp;
                    if (!monster.Resources.TryGetValue("PlayLeftBass", out sbTemp) || !(sbTemp is Storyboard))
                        return;

                    var storyboard = sbTemp as Storyboard;
                    storyboard.Begin();
                }
                
                    ...                
            }
        }

        if (e.Action == NotifyCollectionChangedAction.Remove)
        {
            var mobsToDelete = new List<MonsterUserControl>();
            foreach (MonsterViewModel item in e.OldItems)
            {
                foreach (var mob in game.Children)
                {
                    if ((string)mob.GetValue(TagProperty) == item.Id.ToString()
                        && mob is MonsterUserControl)
                        mobsToDelete.Add(mob as MonsterUserControl);
                }

                // Remove item from internal collection
                foreach (var control in mobsToDelete)
                {
                    if (game.Children.Contains(control))
                        game.Children.Remove(control);
                }
            }
        }
    }

    /// <summary>
    /// Create a monster to add to the grid
    /// </summary>
    /// <param name="context">DataContext</param>
    /// <param name="row">Row</param>
    /// <param name="column">Column</param>
    /// <returns>Monster user control</returns>
    private MonsterUserControl CreateMonster(MonsterViewModel context, int row, int column)
    {
        var mob = new MonsterUserControl();
        mob.DataContext = context;
        mob.SetValue(Grid.RowProperty, row);
        mob.SetValue(Grid.ColumnProperty, column);
        if (column == 0)
            mob.HorizontalAlignment = HorizontalAlignment.Left;
        else
            mob.HorizontalAlignment = HorizontalAlignment.Right;
        mob.VerticalAlignment = VerticalAlignment.Center;
        mob.Tag = context.Id;
        return mob;
    }
}

La dernière chose à faire est d’ajouter notre grille de jeu à notre page actuelle et de lier la DependencyProperty à la collection de monstre du MainViewModel.

MainView.xaml
<Page>
    ...

           <Views:GameGrid Grid.ColumnSpan="2"
                            Monsters="{Binding Monsters, Mode=TwoWay}" />
        </Grid>
    </Grid>
</Page>

Tout est en place pour afficher nos montres, voyons maintenant ce qui va déclencher leur création. Dans la méthode Analyse(), nous allons simplement analyser le spectre lors de chaque boucle et ajouter le monstre à la collection.

Filtrage et séparation des fréquences

L’appel à la fonction getSpectrum() nous a donc découpé le spectre en 512 parties. On peut donc calculer l’écart que couvre chacune de ces plages grâce à un calcul assez simple :

Ecart (Hz) = (Fréquence de la chanson (Hz) / 2) / 512 = (44000 Hz / 2) / 512 = 42,96875 Hz

On va désormais pouvoir trier chacun de nos écarts dans la plage qui lui correspond (grave, medium, aigu) en prenant comme référence les sites http://www.audio-maniac.com/technique-audio/legaliseur et http://www.audiosonica.com/fr/cours/post/28/Perception_du_son_Le_spectre_des_frequences_audibles.

La plage des graves s’étend donc entre 16hz et 200Hz, les mediums de 200Hz à 5000Hz et les aigus de 5000Hz à 20000Hz. On va donc mettre la propriété à true lorsque celle ci est trouvée dans la bonne plage de fréquence avec une puissance suffisante.

MainViewModel.cs
public async void Analyse()
{
    while (IsPlaying)
    {
        ...

        Monsters.Add(GetMonster());

        await Task.Delay(25);
        await Task.Yield();
    }
}

private MonsterViewModel GetMonster()
{
    var vm = new MonsterViewModel();
    var monster = new Monster();

    // Bass (200Hz / 43 = 4,6)
    for (int i = 0; i < 5; i++)
    {
        if (LeftSpectrum[i] >= 0.7f)
            monster.LeftBass = true;
        if (RightSpectrum[i] >= 0.7f)
            monster.RightBass = true;
    }
    // Medium (5000Hz / 43 = 116,3)
    for (int i = 5; i < 116; i++)
    {
        if (LeftSpectrum[i] >= 0.5f)
            monster.LeftMedium = true;
        if (RightSpectrum[i] >= 0.5f)
            monster.RightMedium = true;
    }
    // Low (20000Hz / 43 = 465,1)
    for (int i = 121; i < 465; i++)
    {
        if (LeftSpectrum[i] >= 0.05f)
            monster.LeftLow = true;
        if (RightSpectrum[i] >= 0.05f)
            monster.RightLow = true;
    }

    vm.CurrentMonster = monster;
    return vm;
}

On remarque que le seuil à atteindre pour déclencher la création du monstre dans les basses est différent que celui dans les aigus. Cela est dû au fait que les graves sont plus “puissant” que les mediums ou les aigus. On aura alors tendance à avoir des résultats beaucoup plus grand dans ces plages là. A noter aussi que les seuils que j’ai affecté ici ne dépendent d’aucune formule mathématique ou de concept acoustique, ils sont juste là à des fins de démonstration.

A la fin de cet article, on arrive donc à ce rendu là :

visu_monstre

 

 

Les sources du projet sont disponibles ici : http://sdrv.ms/1atMU5L

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus