IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Communiquez en multicast avec votre Windows Phone sous « Mango »

Ou comment réaliser un minichat pour bavarder discrètement avec ses voisins.

Dans ce tutoriel, je réaliserai une application WinPhone pour « Mango » 7.1 beta 2. Elle permettra à des smartphones d'envoyer des paquets UDP à un groupe multicast. Chaque mobile exécutant cette application, et connecté au groupe, recevra les messages. 3 commentaires Donner une note à l´article (4)

Article lu   fois.

L'auteur

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Le multicasting sur les téléphones Windows, je ne crois pas trop me tromper en disant que les développeurs attendaient cette fonctionnalité depuis longtemps, ne serait-ce « que » pour les jeux. Pour ceux qui ne peuvent pas tester « Mango », il faudra attendre encore un peu. Pour les autres, à vos starting-blocks.

Ce tutoriel adapte le code Windows Phone Peer-to-Peer Multiplayer Game using Sockets in XNAWindows Phone Peer-to-Peer Multiplayer Game using Sockets in XNA du blogueur Ricky_TRicky_T à une application Windows Phone.

II. La classe « UdpAnySourceMulticastClient »

Comme d'habitude, la MSDN est très claire. C'est un récepteur client de trafic multicast. Je vous renvoie vers elle pour éviter de la paraphraser : UdpAnySourceMulticastClient, classeUdpAnySourceMulticastClient, classe.

III. Le programme

Pour bien faire, je l'ai baptisé « Whisper » parce qu'il ne fait pas de bruit et qu'il permet de communiquer discrètement. J'aurais bien utilisé un bruit d'oiseau, mais c'est déjà pris. haha

Il se compose des objets suivants :

  • une classe appelée « UdpChannel » qui va encapsuler le processus de communication ;
  • une classe appelée « UdpPacketEventArgs » pour transférer les données lors des évènements ;
  • un objet métier (une structure) appelé « Whisper » pour transférer les données envoyées par les utilisateurs ;
  • une classe statique pour sérialiser et désérialiser des objets afin de les envoyer sur le réseau ;
  • une page standard (mode portrait).

III-A. La classe « UdpChannel »

La classe UdpChannel
La classe « UdpChannel »

Parmi les points importants cette classe dispose :

  • d'une méthode « Open » afin de facilement établir la liaison avec le groupe multicast ;
  • d'un évènement « OnAfterOpened » qui permettra à l'interface d'afficher un message de bienvenue ;
  • d'une méthode « Receive » afin de facilement mettre le téléphone à l'écoute des paquets UDP ;
  • d'un évènement « OnPacketReceived » qui permettra d'afficher les messages diffusés au groupe.

III-A-1. Création de la liaison au groupe multicast

Pour bien comprendre, je vous invite à lire le tutoriel de François GuillotFrançois Guillot intitulé « Introduction aux délégués en C#Introduction aux délégués en C# ». Tout particulièrement le paragraphe .NET 3.5 et les expressions lambda.NET 3.5 et les expressions lambda.

De même, reportez-vous aux pages suivantes pour avoir plus de détails sur les méthodes BeginJoinGroupBeginJoinGroup et EndJoinGroupEndJoinGroup.

Code de la méthode « Open »
Sélectionnez
/// <summary>
/// Ouverture de la connexion
/// </summary>
public void Open()
{
    if (!isJoined)
    {
        // Lie le socket et commence une opération de jointure au groupe multicast 
        // pour autoriser la réception de datagrammes provenant de tous les participants de groupe.
        this.client.BeginJoinGroup(
            result =>
            {
                try
                {
                    // Termine l'opération d'envoi de groupe de jointure asynchrone à un groupe multicast.
                    this.client.EndJoinGroup(result);
                    isJoined = true;
                    // Appel de la méthode d'après ouverture qui va déclencher l'évènement correspondant.
                    this.AfterOpened();
                    // Mise à l'écoute.
                    this.Receive();
                }
                catch(Exception e)
                {
                    throw e;
                }
            }, null);
    }
}

Aucune information personnelle ne peut-être récupérée par le programme sans l'autorisation de l'utilisateur. La méthode « AfterOpened » ne peut donc pas envoyer le nom de l'appareil ou de son propriétaire. Les messages envoyés sont donc anonymes. Par la suite, l'utilisateur peut saisir un pseudo.

III-A-2. La mise sur écoute

Encore une expression lambda contenant la méthode de rappel (callback). Le programme se met en attente de paquets et exécute la méthode de rappel dès qu'un paquet est reçu. Pour plus de détails, référez-vous aux pages suivantes : BeginReceiveFromGroupBeginReceiveFromGroup et EndReceiveFromGroupEndReceiveFromGroup.

Code de la méthode « Receive »
Sélectionnez
private void Receive()
{
    if (isJoined)
    {
        Array.Clear(this.buffer, 0, this.buffer.Length);

        // Commence l'opération de réception d'un paquet provenant du groupe multicast joint et 
        // appelle le rappel spécifié lorsqu'un paquet est arrivé sur le groupe en provenance de l'un des expéditeurs.
        this.client.BeginReceiveFromGroup(this.buffer, 0, this.buffer.Length, result =>
        {
            if (!isDisposed)
            {
                // Déclaration d'un IPEndPoint qui permettra de connaître l'adresse IP de l'émetteur
                IPEndPoint source;
                try
                {
                    // Termine l'opération asynchrone de réception d'un paquet provenant du 
                    // groupe multicast joint et fournit les informations reçues.
                    this.client.EndReceiveFromGroup(result, out source);
                    // Appel de la méthode d'après réception qui va déclencher l'évènement correspondant.
                    this.AfterReceived(source, this.buffer);
                    // Mise à l'écoute.
                    this.Receive();
                }
                // En cas d'erreur on tente d'ouvrir de nouveau la connexion.
                catch
                {
                    isJoined = false;
                    this.Open();
                }
            }
        }, null);
    }
}

Contrairement à l'établissement de la liaison, la méthode « EndReceiveFromGroup » permet de connaître l'adresse IP de l'émetteur du message. Ce n'est pas une indiscrétion. Cela permet éventuellement d'utiliser la classe « UdpSingleSourceMulticastClientUdpSingleSourceMulticastClient » pour adresser un message destiné uniquement à l'émetteur.

III-A-3. Code complet de « UdpChannel »

J'espère que les commentaires seront suffisants pour la compréhension.

Code complet de « UdpChannel »
Sélectionnez
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace Whisper.PhoneApp
{
    public class UdpChannel : IDisposable
    {
        // Client Udp
        private UdpAnySourceMulticastClient client;
        // Tampon de stockage des messages
        private byte[] buffer = new byte[1024];
        // Port d'écoute et d'émission
        private int localPort;
        // Variable indiquant l'état de connexion avec le groupe
        private bool isJoined;
        // Implémentation de l'interface IDisposable
        private bool isDisposed;
        // Adresse du groupe
        public IPAddress groupAddress { get; private set; }
        // Évènement déclenché lors de la réception d'un paquet
        public event EventHandler<UdpPacketEventArgs> OnPacketReceived;
        // Évènement déclenché après l'ouverture de la connexion au groupe
        public event EventHandler<UdpPacketEventArgs> OnAfterOpened;
        // Évènement déclenché avant la fermeture de la connexion
        public event EventHandler<UdpPacketEventArgs> OnBeforeClosing;
        /// <summary>
        /// Constructeur
        /// </summary>
        /// <param name="address"></param>
        /// <param name="port"></param>
        public UdpChannel(IPAddress address, int port)
        {
            groupAddress = address;
            localPort = port;
            client = new UdpAnySourceMulticastClient(groupAddress, localPort);
        }

        /// <summary>
        /// Ouverture de la connexion
        /// </summary>
        public void Open()
        {
            if (!isJoined)
            {
                // Lie le socket et commence une opération de jointure au groupe multicast 
                // pour autoriser la réception de datagrammes provenant de tous les participants de groupe.
                this.client.BeginJoinGroup(
                    result =>
                    {
                        try
                        {
                            // Termine l'opération d'envoi de groupe de jointure asynchrone à un groupe multicast.
                            this.client.EndJoinGroup(result);
                            isJoined = true;
                            // Appel de la méthode d'après ouverture qui va déclencher l'évènement correspondant.
                            this.AfterOpened();
                            // Mise à l'écoute.
                            this.Receive();
                        }
                        catch (Exception e)
                        {
                            throw e;
                        }
                    }, null);
            }
        }

        private void AfterOpened()
        {
            EventHandler<UdpPacketEventArgs> handler = this.OnAfterOpened;

            if (handler != null)
                handler(this, new UdpPacketEventArgs(string.Empty, string.Empty));
        }

        private void Receive()
        {
            if (isJoined)
            {
                Array.Clear(this.buffer, 0, this.buffer.Length);

                // Commence l'opération de réception d'un paquet provenant du groupe multicast joint et 
                // appelle le rappel spécifié lorsqu'un paquet est arrivé sur le groupe en provenance de l'un des expéditeurs.
                this.client.BeginReceiveFromGroup(this.buffer, 0, this.buffer.Length, result =>
                {
                    if (!isDisposed)
                    {
                        // Déclaration d'un IPEndPoint qui permettra de connaître l'adresse IP de l'émetteur
                        IPEndPoint source;
                        try
                        {
                            // Termine l'opération asynchrone de réception d'un paquet provenant du 
                            // groupe multicast joint et fournit les informations reçues.
                            this.client.EndReceiveFromGroup(result, out source);
                            // Appel de la méthode d'après réception qui va déclencher l'évènement correspondant.
                            this.AfterReceived(source, this.buffer);
                            // Mise à l'écoute.
                            this.Receive();
                        }
                        // En cas d'erreur on tente d'ouvrir de nouveau la connexion.
                        catch
                        {
                            isJoined = false;
                            this.Open();
                        }
                    }
                }, null);
            }
        }

        /// <summary>
        /// Permet d'envoyer les paquets
        /// </summary>
        /// <param name="message"></param>
        public void SendTo(string message)
        {
            byte[] data = Encoding.UTF8.GetBytes(message);
            if (isJoined)
            {
                this.client.BeginSendToGroup(data, 0, data.Length,
                    result =>
                    {
                        this.client.EndSendToGroup(result);
                    }, null);
            }
        }

        /// <summary>
        /// Méthode appelée après la réception d'un paquet
        /// </summary>
        /// <param name="source"></param>
        /// <param name="p"></param>
        private void AfterReceived(IPEndPoint source, byte[] p)
        {
            EventHandler<UdpPacketEventArgs> handler = this.OnPacketReceived;

            if (handler != null)
                // Passage des arguments au gestionnaire: l'adresse IP de l'émetteur, le chuchotement sérialisé
                handler(this, new UdpPacketEventArgs(source.Address.ToString(), Encoding.UTF8.GetString(p, 0, p.Length)));
        }

        private void BeforeClose()
        {
            EventHandler<UdpPacketEventArgs> handler = this.OnBeforeClosing;

            if (handler != null)
                handler(this, new UdpPacketEventArgs(string.Empty, string.Empty));
        }

        #region IDisposable

        public void Dispose()
        {
            if (!isDisposed)
            {
                Dispose(true);
                GC.SuppressFinalize(this);
            }
            isDisposed = true;
        }

        ~UdpChannel()
        {
            Dispose(false);
        }

        private void Dispose(bool disposing)
        {
            if (disposing)
            {
                client.Dispose();
            }
        }

        #endregion
    }
}

III-A-4. Autres objets

Code de la structure « Whisper »
Sélectionnez
namespace Whisper.PhoneApp
{
    public struct Whisper
    {
        public string Message;
        public string User;
    }
}
Code de l'argument « UdpPacketEventArgs »
Sélectionnez
using System;
using System.Text;

namespace Whisper.PhoneApp
{
    public class UdpPacketEventArgs : EventArgs
    {
        public string Message { get; private set; }
        public string Source { get; private set; }

        public UdpPacketEventArgs(string source, string data)
        {
            this.Message = data;
            this.Source = source;
        }
    }
}
Code du sérialiseur désérialiseur
Sélectionnez
using System.IO;
using System.Text;
using System.Xml;
using System.Xml.Serialization;

namespace Whisper.PhoneApp
{
    public static class Serializer<T>
    {
        public static string SerializeMe(T obj)
        {
            XmlSerializer serializer = new XmlSerializer(typeof(T));

            using (MemoryStream mem = new MemoryStream())
            {
                serializer.Serialize(mem, obj);
                return Encoding.UTF8.GetString(mem.ToArray(), 0, mem.ToArray().Length);
            }
        }

        public static T DeserializeMe(string obj)
        {
            // Cette ligne est importante pour éviter une erreur de caractère hexadécimal invalide.
            obj = obj.Replace("\n", "").Replace("\0", "");

            XmlSerializer serializer = new XmlSerializer(typeof(T));

            using (MemoryStream mem = new MemoryStream(Encoding.UTF8.GetBytes(obj)))
            {
                using (XmlReader reader = XmlReader.Create(mem))
                {
                    return (T)serializer.Deserialize(reader);
                }
            }
        }
    }
}

Pour je ne sais quelle raison, il faut remplacer les caractères « \n » et « \0 ». Si on ne le fait pas, on risque une erreur hexadecimal value 0x00, is an invalid character. Il y a pas mal de discussions à ce propos sur Internet. Peut-être est-ce dû à la taille du tableau fixée à 1024. Les espaces vides sont comblés avec des valeurs nulles.

III-B. La page principale

Son rôle va être d'ouvrir le canal UDP, de gérer la saisie des messages et leur affichage lors de l'émission ou de la réception. Voici le code XAML et le résultat en mode design.

III-B-1. Le fichier XAML

Code du fichier XAML
Sélectionnez
<phone:PhoneApplicationPage 
    x:Class="Whisper.PhoneApp.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:phone="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone"
    xmlns:shell="clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="768"
    FontFamily="{StaticResource PhoneFontFamilyNormal}"
    FontSize="{StaticResource PhoneFontSizeNormal}"
    Foreground="{StaticResource PhoneForegroundBrush}"
    SupportedOrientations="Portrait" Orientation="Portrait"
    shell:SystemTray.IsVisible="True">
    <Grid x:Name="LayoutRoot" Background="Transparent">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28">
            <TextBlock x:Name="ApplicationTitle" Text="Whisper" Style="{StaticResource PhoneTextNormalStyle}"/>
            <TextBlock x:Name="PageTitle" Text="Chat window" Margin="9,-7,0,0" Style="{StaticResource PhoneTextTitle1Style}"/>
        </StackPanel>
        <Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
            <TextBox Height="72" HorizontalAlignment="Left" Margin="0,0,0,0" Name="textBox1" Text="Say something" 
			VerticalAlignment="Top" Width="460" GotFocus="textBox1_GotFocus" />
            <Button Content="Send" Height="72" HorizontalAlignment="Left" Margin="0,70,0,0" Name="button1" 
			VerticalAlignment="Top" Width="160" Click="button1_Click" />
            <ScrollViewer VerticalScrollBarVisibility="Auto" Margin="0,148,0,6" Name="scroller1">
                <TextBlock HorizontalAlignment="Left" Margin="0,0,0,0" Name="textBlock1" Text="" 
				VerticalAlignment="Top" Width="450" ScrollViewer.VerticalScrollBarVisibility="Auto" />
            </ScrollViewer>
            <TextBox Height="72" HorizontalAlignment="Right" Margin="0,70,0,0" Name="textBox2" Text="Username" 
			VerticalAlignment="Top" Width="300" GotFocus="textBox2_GotFocus" />
        </Grid>
    </Grid>
</phone:PhoneApplicationPage>
La page principale en mode design.
La page principale en mode design.

III-B-2. Code behind

Code behind de la page principale
Sélectionnez
using System;
using System.Net;
using System.Text;
using System.Windows;
using Microsoft.Phone.Controls;

namespace Whisper.PhoneApp
{
    public partial class MainPage : PhoneApplicationPage
    {
        // Déclaration du canal
        UdpChannel channel;
        // Déclaration et instanciation d'un chuchotement
        Whisper whisper = new Whisper();

        public MainPage()
        {
            InitializeComponent();

            // Instanciation du canal sur l'adresse multicast 224.109.108.106 et le port 3000
            channel = new UdpChannel(IPAddress.Parse("224.109.108.106"), 3000);
            // Abonnement de la page à l'évènement OnAfterOpened du canal
            this.channel.OnAfterOpened += new EventHandler<UdpPacketEventArgs>(channel_OnAfterOpen);
            // Abonnement de la page à l'évènement OnPacketReceived du canal
            this.channel.OnPacketReceived += new EventHandler<UdpPacketEventArgs>(Channel_PacketReceived);
            // Ouverture du canal
            this.channel.Open();
        }

        /// <summary>
        /// Méthode appelée après l'ouverture du canal UDP
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void channel_OnAfterOpen(object sender, UdpPacketEventArgs e)
        {
            // Affichage d'un message informant l'utilisateur de la connexion
            textBlock1.Text = "You joined the group " + channel.groupAddress;
            whisper.User = textBox2.Text;
            whisper.Message = "Hello";
            // Envoi d'un petit bonjour au groupe
            channel.SendTo(Serializer<Whisper>.SerializeMe(whisper));
        }

        /// <summary>
        /// Méthode appelée lors de la réception d'un paquet UDP
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void Channel_PacketReceived(object sender, UdpPacketEventArgs e)
        {
            // Désérialisation du message
            whisper = Serializer<Whisper>.DeserializeMe(e.Message);

            // Mise à jour différée de l'interface pour éviter une erreur "Invalid cross-thread access"
            textBlock1.Dispatcher.BeginInvoke(() =>
            {
                textBlock1.UpdateLayout();
                // Affichage du message
                textBlock1.Text += Environment.NewLine + whisper.User + " says: " + whisper.Message;
                scroller1.UpdateLayout();
                // Déplacement de l'ascenseur jusqu'au dernier message reçu
                scroller1.ScrollToVerticalOffset(textBlock1.ActualHeight);
            });
        }

        private void button1_Click(object sender, RoutedEventArgs e)
        {
            // Assignation de la saisie sur l'instance de l'objet Whisper
            whisper.User = textBox2.Text;
            whisper.Message = textBox1.Text;
            // Sérialisation et envoi du message
            channel.SendTo(Serializer<Whisper>.SerializeMe(whisper));
            // Effacement du contenu de la textbox
            textBox1.Text = string.Empty;
        }

        private void textBox1_GotFocus(object sender, RoutedEventArgs e)
        {
            // Effacement du contenu de la textbox lors du focus
            textBox1.Text = string.Empty;
        }

        private void textBox2_GotFocus(object sender, RoutedEventArgs e)
        {
            // Effacement du contenu de la textbox lors du focus
            textBox2.Text = string.Empty;
        }
    }
}

III-C. Test

Compilez l'application en mode « Release ». Déployez-la sur le mobile avec l'outil « Application Deployment » en sélectionnant le fichier « xap » du dossier « \Bin\Release ».

Sur Visual Studio, lancez l'application en mode « Debug » ou « Release » selon ce que vous souhaitez. Parallèlement, sélectionnez l'application dans la liste du téléphone. La page principale s'affiche rapidement. Lors du démarrage, l'application envoie un premier message au groupe avec les valeurs par défaut des « TextBox ». La première à être prête affichera deux fois le même message de bienvenue.

Les images ci-dessous montrent l'émulateur et le mobile. Dans l'émulateur, j'ai saisi mon pseudo ainsi que le message « hello world! ». Puis, une fois le bouton « Send » actionné, le message a été envoyé au groupe. Tous les appareils liés l'ont reçu et affiché.

L'émulateur et le téléphone échangent des messages.
L'émulateur et le téléphone échangent des messages.

IV. Pour aller plus loin et autres détails

La concaténation des messages du bloc n'est certainement pas la meilleure façon de procéder. Il vaudrait mieux garder en mémoire une liste des objets « Whisper » échangés et lier cette source de données avec un contrôle. Cette source de données pourrait être sauvegardée localement par la suite.

Il faudrait probablement vérifier que le message envoyé ne dépasse pas la taille du tampon sinon Aïe.

Quand le thread est occupé à recevoir ou écouter il n'est pas possible de modifier l'interface utilisateur. Il faut donc faire appel au « DispatcherDispatcher » qui va mettre ces modifications dans la file d'attente.

Les performances de l'application seront améliorées grâce à l'utilisation de la sérialisation JSONsérialisation JSON. En effet, celle-ci envoie moins de texte que la sérialisation XML. Voici deux méthodes à ajouter à la classe « Serializer<T> » pour utiliser ce mode de sérialisation.

Méthode générique de sérialisation JSON
Sélectionnez
public static string JsonSerializeMe(T obj)
{
    using (MemoryStream mem = new MemoryStream())
    {
        DataContractJsonSerializer ser = new DataContractJsonSerializer(typeof(T));
        ser.WriteObject(mem, obj);
        byte[] s = mem.ToArray();
        return Encoding.UTF8.GetString(s, 0, s.Length);
    }
}
Méthode générique de désérialisation JSON
Sélectionnez
public static T JsonDeserializeMe(string obj)
{
    // Cette ligne est importante pour éviter une erreur de caractère hexadécimal invalide.
    obj = obj.Replace("\n", "").Replace("\0", "");

    using (MemoryStream mem = new MemoryStream(Encoding.UTF8.GetBytes(obj)))
    {
        DataContractJsonSerializer ser = new DataContractJsonSerializer(typeof(T));
        return (T)ser.ReadObject(mem);
    }
}

Pour pouvoir les utiliser, il faut ajouter les références aux assemblies « System.ServiceModel.Web » et « System.Runtime.Serialization » au projet. Il faut aussi ajouter les attributs « DataContract » et « DataMember » comme dans le code ci-dessous :

Code de la structure « Whisper »
Sélectionnez
using System.Runtime.Serialization;

namespace Whisper.PhoneApp
{
    [DataContract]
    public struct Whisper
    {
        [DataMember]
        public string Message;
        [DataMember]
        public string User;
    }
}

La page pricipale doit aussi être modifiée pour utiliser les bonnes méthodes. Vous pouvez le faire manuellement ou bien implémenter un délégué. Comme les méthodes de sérialisation XML et JSON ont la même signature, vous pouvez déclarer des délégués pour gérer l'une et l'autre des méthodes. Ainsi, si vous souhaitez changer pour l'une ou l'autre des méthodes de sérialisation, il suffira d'intervenir uniquement sur l'instanciation des délégués.

Délégation de la sérialisation
Sélectionnez
public partial class MainPage : PhoneApplicationPage
{
    delegate string SerializerDelegate(Whisper obj);
    delegate Whisper DeserializerDelegate(string obj);

    SerializerDelegate serializer = new SerializerDelegate(Serializer<Whisper>.JsonSerializeMe);
    DeserializerDelegate deserializer = new DeserializerDelegate(Serializer<Whisper>.JsonDeserializeMe);
    
    /*
    * Reste du code
    */
}

Il suffit ensuite d'appeler le bon délégué lorsque c'est nécessaire.

Sérialisation et envoi d'un message
Sélectionnez
	channel.SendTo(serializer(whisper));
Désérialisation d'un message
Sélectionnez
	whisper = deserializer(e.Message)

Conclusion

Malheureusement, ce programme nécessite un point d'accès pour fonctionner. Les deux appareils doivent être sur le même réseau. Il ne s'agit pas d'une connexion ad hoc. Malgré ce petit inconvénient, ce nouveau mode de communication ouvre de nouveaux horizons pour les téléphones Windows. Nous allons pouvoir développer des applications clientes (sans serveur) multiutilisateurs. Cela promet des activités très ludiques, jouables partout sans connexion Internet.

Remerciements

Merci à GuruuMeditationGuruuMeditation d'avoir résolu mon bogue. Merci à ClaudeLELOUPClaudeLELOUP pour sa relecture. Cool

Références supplémentaires

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2011 Immobilis. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts. Droits de diffusion permanents accordés à Developpez LLC.