lundi 11 novembre 2013

Tutorial Unity3D: Construire ses propres outils - Partie 3

Hello tout le monde.

Avec seulement 6 mois de retard (continue de sourire, ça va passer), voici la partie 3 de cette suite de tutos.

Dans cette partie, on va apprendre a modifier Unity en intégrant nos propres outils directement dans son interface (ce n'est pas sale !). L'idée de modifier Unity peut paraître effrayante et/ou difficile. Mais on va voir que ce n'est pas bien différent des scripts que vous êtes habitués a écrire.

Comme d'habitude je préviens que ce tutorial n'a en rien la prétention d'être complet. Son but est surtout de vous aider a vous lancer tout en vous expliquant les bases. Pour le reste la documentation d'Unity vous tend les bras^^.

Allez, c'est parti !




Ce qu'on va réaliser



Pour avoir un cadre de travail, on va coder quelque chose de bien concret, un "éditeur de niveaux" très basique. Ce petit éditeur permet de copier/coller, déplacer et orienter des objets très facilement et de manière précise. Ça permet par exemple de placer des murs à la suite l'un de l'autre très rapidement en étant sûr qu'il n'y aura pas d'overlaps.

Il ressemble à ça, perso je l'utilise tous les jours.



Il est axé 2D mais comme vous le voyez, il y a deux petites rajoutes 3D (Y Up et Y Down) que j'avais faites pour mon prototype de Spacesim.

Vous aurez sans doute aussi besoin de changer les axes utilisés si vous n'utilisez pas les mêmes que moi. J'utilise toujours l'axe Y comme axe vertical. Ce qui fait que les coordonnées de "Song of the Myrne" se font en (X, Z) et non pas en (X, Y) comme certains font.

L'interface



On va commencer par la partie visuelle. Je vais vous apprendre a créer une fenêtre, y placer du texte, des boutons, etc...

Vous allez voir que c'est presque la même chose que de faire une interface normale. La principale différence c'est qu'on va généralement éviter de travailler avec des Rect, on préférera souvent que les éléments se placent à la suite l'un de l'autre dans la fenêtre plutôt qu'en donnant des dimensions et un positionnement fixe à nos éléments.

Notez qu'on peux quand-même le faire, dans ce cas on utilisera les GUI.Button, GUI.Label,... habituels.

Créer une fenêtre

Commençons par créer un script (je travaille en javascript). Mais attention ! Pas n'importe où. Les scripts qui modifient Unity, soit l'éditeur, se placent dans le dossier "Assets/Editor". Si vous ne le mettez pas là, et uniquement là (ou dans un sous-dossier de celui-ci), ça ne fonctionnera pas. On appelle ces scripts des EditorScripts.

Une fois votre script créé, appelons-le "Editor2D.js" par exemple, on va y écrire le code de base pour la création d'une fenêtre:

  • Pour commencer on crée une classe "Editor2D" qui se base sur la classe EditorWindow :

class Editor2D extends EditorWindow {
}


  • Dans cette classe on va ajouter une des fameuses commandes @ vues dans la partie 2 du tuto.
@MenuItem ("Window/Editor2D")

Cela va permettre d’appeler notre fenêtre dans Unity, parce que faire une fenêtre, c'est cool, mais si on ne peux pas y accéder, ça sert un peu à rien^^.

  • Bien, maintenant on peux créer la fenêtre proprement dite (le nom de la fonction n'a pas d'importance):
static function Init ()
    {  
        var window = ScriptableObject.CreateInstance.();
        window.Show();
    }


  • On ajoute une fonction OnGUI() et puis c'est bon !
A ce stade, votre script doit ressembler à ça:


Dans Unity, si vous allez dans le menu Window et que vous cliquez sur Editor2D, il devrait vous ouvrir une fenêtre vide.

On va bientôt ajouter du contenu à cette fenêtre, Unity est un peu capricieux et ne veux pas toujours rafraîchir le contenu de la fenêtre (de mon temps, il ne le faisait pas du tout automatiquement, ne vous plaignez pas^^), voilà donc ce qu'il faut faire pour la rafraîchir:

  • Simplement cliquer ou double-cliquer sur la fenêtre pour prendre le focus, en général ça la rafraîchit et vous pouvez voir l'effet de vos modifications dedans.
  • Si ça ne fonctionne pas, redimensionnez-là, en principe ça marche à tous les coups
  • Si vraiment ça ne fonctionne pas, pensez a ajouter un bouton appelant la fonction Repaint(); ou fermez puis ré-ouvrez la fenêtre.
Bien, elle est bien cool notre fenêtre, mais elle fait un peu vide.

Le contenu de l'interface


C'est ici qu'un bon 90% du travail est fait.

Ce qu'il faut savoir avant de commencer, c'est qu'Unity va remplir notre fenêtre de haut en bas, dans l'ordre de ce qu'il trouvera dans le OnGUI (puisque comme je l'ai dit, on ne va pas donner de positions fixes à nos éléments).

Outils



Voici la liste des outils qu'on va utiliser pour quand-même mettre un peu d'ordre dans notre fenêtre:

GUILayout.BeginHorizontal();
GUILayout.EndHorizontal();

Ces deux commandes vont nous permettre d'ouvrir puis de fermer une zone horizontale. C'est à dire que tout le contenu entre ces deux lignes va s'aligner de gauche à droite et non plus de haut en bas. Comme vous l'avez vu sur le screen de notre éditeur, nos "tableaux de contrôle" (Move, Rotation et Copy) sont placés les uns à côtés des autres, c'est grâce à ces commandes.

GUILayout.Space(tailleEnPixel);

Cette ligne-ci va ajouter un espace vide entre deux éléments. Cet espace sera vertical par défaut, mais horizontal si la ligne se trouve entre un BeginHorizontal et un EndHorizontal.

Au niveau des boutons, labels, etc, on va chaque fois utiliser la classe GUILayout plutôt que la classe GUI. Ce qui comme je l'ai dit, nous permettra de ne pas donner de position fixe à nos éléments.

Ainsi, GUI.Button(Rect(posX, posY, sizeX, sizeY), "Click me") deviendra GUILayout.Button("Click me");

Rien de bien nouveau ici.

Sauf que par défaut, les boutons, les labels,... prendront toute la largeur disponible (la largeur de la fenêtre si le bouton est seul, la moitié s'il y en a deux, etc). Comme ce n'est pas forcément joli, on utilisera GUILayout.Width(tailleEnPixel) pour donner une taille à un élément.

Exemple: GUILayout.Button("Click me", GUILayout.Width(sizeX));

Bien, vous avez tout bien retenu ? Alors c'est parti !

Promis, cette fois on tape du code !

Pour commencer, déclarez la variable var sizeOfObject:int = 10; au tout début de la classe Editor2D. Cette variable va permettre de préciser à notre éditeur la taille des objets qu'on manipule. En gros, si je la laisse par défaut, à 10 (c'est la taille de mes petits carrés dans Song of the Myrne), et que j'appuie sur "Move Right" avec un objet sélectionné, cet objet va être déplacé de 10 unités sur la droite.

A partir d'ici, tout se passe dans la fonction OnGUI si je ne précise rien.

On va donc écrire notre interface en commençant par le haut, On a donc les options (l'option unique en fait^^) de l'éditeur.

// Ces trois variables serviront plus tard
var sizeButtons:int = 130;
var ecartPanneaux:int = 30; // L'écart entre nos panneaux de contrôles
var ecartTitles:int = sizeButtons; // L'écart entre le titre "Move" et le bord gauche de la fenêtre

// Le deuxième argument permet simplement de mettre le texte en gras
GUILayout.Label ("Base Settings", EditorStyles.boldLabel);
     
GUILayout.Space(10); // Cet espace n'est pas dans une zone Horizontale, il sera donc vertical
     
// On commence une zone horizontale pour que la suite s'affiche en ligne et pas en colonne
GUILayout.BeginHorizontal();      
sizeOfObject = EditorGUILayout.IntField("Size of Object", sizeOfObject, GUILayout.Width(250));
if(GUILayout.Button("-", GUILayout.Width(40)))
{
     if(sizeOfObject > 10)
     sizeOfObject -= 10;
}
if(GUILayout.Button("+", GUILayout.Width(40)))
sizeOfObject += 10;
GUILayout.EndHorizontal();

L'étape suivante consiste a afficher les trois panneaux de contrôles. Y'a vraiment rien de nouveau ici donc je vous donne le code complet de la fonction OnGUI. Je pense avoir déjà expliqué tout ce qui s'y trouve.

function OnGUI ()
    {
    var sizeButtons:int = 130;
    var ecartPanneaux:int = 30;
    var ecartTitles:int = sizeButtons;
   
        GUILayout.Label ("Base Settings", EditorStyles.boldLabel);
     
        GUILayout.Space(10);
     
        GUILayout.BeginHorizontal();      
        sizeOfObject = EditorGUILayout.IntField("Size of Object", sizeOfObject, GUILayout.Width(250));
        if(GUILayout.Button("-", GUILayout.Width(40)))
        {
        if(sizeOfObject > 10)
        sizeOfObject -= 10;
        }
if(GUILayout.Button("+", GUILayout.Width(40)))
sizeOfObject += 10;
GUILayout.EndHorizontal();
     
GUILayout.Space(10);
     
        GUILayout.BeginHorizontal();
        var decalTitre:int[] = new int[3];
        decalTitre[0] = -25;
        decalTitre[1] = -decalTitre[0]+ecartPanneaux-20;
        decalTitre[2] = -decalTitre[0]-decalTitre[1]+ecartPanneaux+40;
        GUILayout.Space(ecartTitles+decalTitre[0]);
        GUILayout.Label ("Move", EditorStyles.boldLabel, GUILayout.Width(sizeButtons));
        GUILayout.Space(ecartTitles+decalTitre[1]);
        GUILayout.Label ("Rotation", EditorStyles.boldLabel, GUILayout.Width(sizeButtons));
        GUILayout.Space(ecartTitles+decalTitre[2]);
        GUILayout.Label ("Copy", EditorStyles.boldLabel, GUILayout.Width(sizeButtons));
        GUILayout.EndHorizontal();
     
        GUILayout.Space(10);
     
        GUILayout.BeginHorizontal();
        GUILayout.Space(sizeButtons/2);
        if(GUILayout.Button("Up", GUILayout.Width(sizeButtons))) //Move Up
        Move("Up");
        GUILayout.Space(sizeButtons + ecartPanneaux);
        if(GUILayout.Button("0", GUILayout.Width(sizeButtons))) //Rotation 0
        SetRotation(0);
        GUILayout.Space(sizeButtons + ecartPanneaux);
        if(GUILayout.Button("Up", GUILayout.Width(sizeButtons))) //Copy Up
        Copy("Up");
        GUILayout.EndHorizontal();
     
        GUILayout.BeginHorizontal();
        if(GUILayout.Button("Left", GUILayout.Width(sizeButtons))) //Move Left
        Move("Left");
        if(GUILayout.Button("Right", GUILayout.Width(sizeButtons))) //Move Right
        Move("Right");
       
        GUILayout.Space(ecartPanneaux);
       
        if(GUILayout.Button("-90", GUILayout.Width(sizeButtons))) //Rotation -90
        SetRotation(-90);
        if(GUILayout.Button("90", GUILayout.Width(sizeButtons))) //Rotation 90
        SetRotation(90);
       
        GUILayout.Space(ecartPanneaux);      
        if(GUILayout.Button("Left", GUILayout.Width(sizeButtons))) //Copy Left
        Copy("Left");
        if(GUILayout.Button("Right", GUILayout.Width(sizeButtons))) //Copy Right
        Copy("Right");
        GUILayout.EndHorizontal();
       
GUILayout.BeginHorizontal();
GUILayout.Space(sizeButtons/2);
        if(GUILayout.Button("Down", GUILayout.Width(sizeButtons))) //Move Down
        Move("Down");
        GUILayout.Space(sizeButtons + ecartPanneaux);
        if(GUILayout.Button("180", GUILayout.Width(sizeButtons))) //Rotation 180
        SetRotation(180);
        GUILayout.Space(ecartPanneaux + sizeButtons);
        if(GUILayout.Button("Down", GUILayout.Width(sizeButtons))) //Copy Down
        Copy("Down");
        GUILayout.EndHorizontal();
       
        GUILayout.Space(15);
       
GUILayout.BeginHorizontal();
GUILayout.Space(sizeButtons/2);
        if(GUILayout.Button("Y Up", GUILayout.Width(sizeButtons))) //Move UP on Y
        Move("Y Up");
        GUILayout.Space(sizeButtons*3 + ecartPanneaux*2);
        if(GUILayout.Button("Y Up", GUILayout.Width(sizeButtons))) //Copy UP on Y
Copy("Y Up");
        GUILayout.EndHorizontal();
       
GUILayout.BeginHorizontal();
GUILayout.Space(sizeButtons/2);
if(GUILayout.Button("Y Down", GUILayout.Width(sizeButtons))) //Move Down on Y
Move("Y Down");
GUILayout.Space(sizeButtons*3 + ecartPanneaux*2);
        if(GUILayout.Button("Y Down", GUILayout.Width(sizeButtons))) //Copy Down on Y
Copy("Y Down");
GUILayout.EndHorizontal();
    }

Voilà, en principe avec ça, l'interface devrait être complète.

Horreur ! Ça ne compile plus ! Espèce de vile engeance du démon ! Tu m'a donné du code pété !

Mais non, pas de panique, ce code appelle les fonctions qu'il nous reste a coder. Mettez les appels de fonctions en commentaire si vous voulez voir ce que donne votre interface.

Les fonctions



Bon, avoir une belle coquille toute vide, c'est bien joli. Mais ça sert un peu à rien.

C'est pourquoi on va maintenant coder les 3 fonctions Move(), Copy() et SetRotation() qui vont faire tout le boulot dès qu'on appuiera sur un bouton de l'interface.

Elles sont bien sûr a placer dans la classe Editor2D. Ça peut paraître évident mais bon, perso je n'écris jamais le "classe machinTruc{}" sauf pour mes fenêtres, alors...

On va commencer par les deux fonctions les plus simples: Move() et SetRotation(). Ici il n'y a qu'un seul truc nouveau. Il s'agit de la classe "Selection".

En gros c'est tout con, si on veux accéder aux Transforms qui sont sélectionnés dans l'éditeur Unity, on utilisera:

for(var trans:Transform in Selection.transforms)

Rien de bien difficile ici. Voilà le code des fonctions:

function Move(direction:String)
    {
    if(direction == "Left")
    {
    for(var trans:Transform in Selection.transforms)
    trans.position.x -= sizeOfObject;
    }
    else if(direction == "Right")
    {
    for(var trans:Transform in Selection.transforms)
    trans.position.x += sizeOfObject;
    }
    else if(direction == "Up")
    {
    for(var trans:Transform in Selection.transforms)
    trans.position.z += sizeOfObject;
    }
    else if(direction == "Down")
    {
    for(var trans:Transform in Selection.transforms)
    trans.position.z -= sizeOfObject;
    }
    else if(direction == "Y Up")
    {
    for(var trans:Transform in Selection.transforms)
    trans.position.y += sizeOfObject;
    }
    else if(direction == "Y Down")
    {
    for(var trans:Transform in Selection.transforms)
    trans.position.y -= sizeOfObject;
    }
    }

function SetRotation(rot:float)
{
for(var objet:GameObject in Selection.gameObjects)
   {
    objet.transform.localEulerAngles.y = rot;
   }
}

La fonction Copy() ajoute une nouveauté à cause de la relation des objets avec leurs Prefabs. J'ai donc deux versions de cette fonction, choisissez celle que vous préférez ou ajouter les toutes les deux à l'éditeur avec une petite option de choix.

La première version ne contient pas la nouveauté, voilà déjà son code.

function Copy(direction:String)
    {    
    for(var objet:GameObject in Selection.gameObjects)
    {
    var instObj:GameObject = GameObject.Instantiate(objet, objet.transform.position, objet.transform.rotation);
    instObj.name = objet.gameObject.name; // évite d'avoir (Clone) dans le nom
    }
   
    Move(direction);
    }

L'inconvénient de cette version, c'est que si l'objet qu'on clone est lié à un prefab, la copie (qui en plus se met à la place précédemment occupée par cet objet) ne sera pas du tout liée à ce prefab. Autrement dit, si vous clonez un mur 500 fois, puis que vous ajoutez un script au préfab de ce mur, aucun de ces murs ne subira la modification. Vraiment pas conseillé...

Pour palier à ce problème j'ai écrit une seconde version de la fonction. Cette deuxième version utilise la classe PrefabUtility et va créer un nouvel objet à partir du prefab de l'objet qu'on veux cloner.

La voici :

function Copy(direction:String)
{
   for(var objet:GameObject in Selection.gameObjects)
   {
    var instObj:GameObject;
   
    var prefab:GameObject;
    if(PrefabUtility.GetPrefabParent(objet))
    {
    prefab = PrefabUtility.GetPrefabParent(objet);
        instObj = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
       }
       else
        instObj = Instantiate(objet,objet.transform.position,Quaternion.identity);
       instObj.transform.position = objet.transform.position;
       instObj.transform.rotation = objet.transform.rotation;

       instObj.name = objet.name; // évite d'avoir "(Clone)" dans le nom
   }
   Move(direction);
}

Elle n'est pas sans défaut elle non plus. Comme je l'ai dit, elle crée un objet à partir du prefab de cet objet, et ne tient finalement pas compte de l'objet lui-même.

Le soucis avec ça c'est que si vous avez, par exemple changé la texture d'un mur lié à un prefab, et que vous clonez ce mur, le mur cloné (qui une fois encore prendra la place de l'ancien mur, gardez le en tête si vous utilisez l'éditeur tel quel^^) ne possédera pas la nouvelle texture mais l'ancienne (celle du prefab donc). Dans cette situation, je créerais un nouveau prefab pour le mur possédant une texture différente, mais c'est pas toujours faisable, on ne va pas créer 500 préfabs pour un même objet^^.

Voilà voilà. En principe votre éditeur devrait fonctionner et votre code ressembler à ça:

http://i.imgur.com/gX2L4XI.png

Vous pouvez aussi télécharger mon script complet à cette adresse.

Une dernière petite astuce pour la route



Vous voilà, intrépides codeurs, prêts à conquérir le monde avec vos propres scripts. Et très vite vous allez vous retrouvez a modifier les valeurs d'un script, depuis un EditorScript. Alors retenez bien ceci ! Ne retenez pas forcément la commande ni quoi que ce soit, mais rappelez-vous que dans un coin obscur du web, un mec qu'on appelle Beldarak vous en a parlé, et alors à ce moment là vous pourrez revenir ici, et les yeux larmoyants, me remercier du fond du coeur pour les heures de galère que je vais vous épargner dans quelques instants.

Unity, n'aime pas trop qu'on modifie les valeurs d'un script (attaché à un objet donc) depuis un de nos propres EditorScripts. Il va alors faire un truc très chiant :

  • Dans un premier temps, tout va fonctionner
  • Vous allez ensuite appuyer sur "Play" et tester le jeu... Et vous rendre compte (ou pas, c'est là toute la perfidie du truc) qu'en fait votre modification n'a pas été prise en compte.
Pourquoi ? Parce que si dans un premier temps, Unity aura bien fait le changement des valeurs du script que vous modifiez depuis un EditorScript, il ne l'a en fait pas enregistré. Et donc dès qu'on appuie sur Play, il "oublie" le changement.

Une commande va donc vous sauver la vie, même si vous ne le savez pas encore^^

EditorUtility.SetDirty(leScriptModifié);

Ce que fais cette commande, c'est de dire à Unity que vous avez modifié votre script, et qu'il doit donc le sauvegarder. Appelez là de la manière que vous voulez, quand vous voulez (mais avant d'appuyer sur Pause ou de quitter Unity), mais appelez-là !

Elle prend un script comme argument. Donc par exemple, si vous venez de modifier un objet "Porte" pour que son script "Door" soit en position "ouverte" (un boolean donc), on devrait retrouver ça dans votre EditorScript:

porte.GetComponent(Door).open = true;
EditorUtility.SetDirty(porte.GetComponent(Door));

Et voilà, cette astuce vous sera probablement utile un jour.

Conclusion



Et voilà, c'est tout pour cette partie 3. Encore désolé pour le délai entre cette partie et la deuxième, au moins ça m'aura permis d'apprendre un peu le html entre temps et d'améliorer un peu la mise en page^^.

Voici quelques pistes pour aller plus loin et voir ce qu'il y a moyen de faire avec les EditorScripts (et qu'en principe vous devriez être capables de faire désormais) :


  • Lancer des fonctions toutes simples depuis l'éditeur, genre positionner le joueur sur l'objet sélectionné
Je ne l'ai pas encore dit mais pour lancer une fonction depuis l'éditeur, il suffit d'écrire un EditorScript (n'oubliez pas de le mettre dans le dossier "Assets/Editor") qui n'ouvre pas de fenêtre et de mettre: @MenuItem ("Mes Scripts Persos/Ma Fonction Trop Bien") juste avant la déclaration de la fonction qu'on souhaite exécuter depuis l'éditeur. En fait, c'est ce qu'on a fait tantôt avec la fonction Init() qui appelait la fenêtre de notre éditeur 2D.

Ces petites fonctions peuvent faire gagner un temps fou sur le long terme.

  • Un éditeur de niveaux bien complet (ça j'ai encore jamais tenté^^)
  • Un éditeur de dialogues intégré dans Unity (ça non plus)
Et cetera et cetera,... Les possibilités sont infinies.

Voilà, sur ce, je vous laisse. 

Codez bien, les enfants !