Bivwac

Construire des visualisations immersives pour favoriser la compréhension, la prise de conscience, et le bien-être

Tulibee

Documentation Tulibee

Sommaire :

1 - Introduction :

1.1 - Contexte :

Le but de ce document est de décrire le fonctionnement de la passerelle Unity - Otree que nous avons créée. Il s’agit d’une passerelle permettant d’échanger des données entre un serveur Otree et des projets tournants sous Unity. Le but étant de pouvoir mettre en place des expériences en multijoueur sous Unity grâce à la base de données d’un serveur.

Le document décrit donc le fonctionnement de cet outil à travers un jeu VR en multijoueur servant de proof of concept. Nous verrons donc dans un premier temps comment installer ce projet et se servir de la passerelle. Puis comment cette dernière fonctionne d’un point de vue technique et comment l’étendre à vos propres projets.

1.2 - Otree :

Otree est un framework python utilisé dans de nombreux domaines liée à la gestion de données tel que les expériences comportementales, les enquête, l’économie ou encore la création de jeu vidéo. Reliée à un serveur il permet alors de mettre en place des expériences multijoueur.

Pour créer un projet Otree deux options s’offrent à vous, soit via la programmation en python, soit via https://www.otreehub.com/ et son outil Otree Studio. Ce dernier permet de créer un projet sans avoir recours à la programmation. Dans notre exemple nous avons donc le projet “zzv-new_project” créé via Otree Studio.

1.3 - Unity :

Unity est un moteur de jeu, un logiciel capable de gérer la géométrie et la physique dans un environnement donné afin de proposer un simulateur en temps réel. Bien qu’utilisé principalement dans le développement de jeux vidéo, il sert aussi dans d’autres domaines tel que la création d’expériences scientifiques.

1.4 - Fishing game :

L’objectif de ce projet est d’avoir un jeu de pêche en multijoueur qui sert de proof of concept à l’utilisation de notre bridge. En effet les données du jeu tel que les identifiants des participants, le nombre de poissons attrapé et le nombre de poissons restant dans le bassin sont transmis par l’application Unity au serveur Otree.

Le principe du jeu est le suivant :

Il s’agit d’un jeu multijoueur où chaque joueur porte un casque (Meta Quest 3) lui permettant d’observer devant lui des poissons nageant dans un bassin en réalité augmentée. Les joueurs peuvent pêcher les poissons en les visant avec leurs contrôleurs. En parallèle, la population de poissons dans le bassin augmente régulièrement, dépendamment du nombre de poissons actuellement présent.

Si le nombre de poissons passe en dessous de 2, la croissance de la population se stoppe, tandis que le nombre maximum de poissons dans le bassin est limité à 30. Il s’agit donc d’une expérience de bien collectif (Common-pool resource) visant à étudier le comportement des joueurs dans une situation type.

Le schéma de contrôle du jeu est le suivant :

Pour attraper un poisson, un joueur doit donc viser sa cible avec son contrôleur afin qu’elle se trouve dans sa zone d’action, puis presser la gâchette. Entre chaque tentative, le joueur doit attendre 1.5 seconde. Ce temps de recharge est illustré par le cercle de la zone d’action se remplissant à nouveau.

À noter qu’il est également possible d’attraper plusieurs poissons en même temps.

Les joueurs peuvent consulter le nombre de poissons attrapés ainsi que ceux restant dans le bassin sur le panneau présent dans la scène.

Le jeu continue jusqu’à l’épuisement du nombre de poissons dans le bassin ou l’arrêt des joueurs.

Maintenant que le gameplay à été explicité. La partie suivante décrit les étapes à suivre pour installer le jeu et créer ses propres sessions.

2 - Installation :

2.1 - OtreeCore :

L’ensemble des fichiers nécessaires à l‘installation du bridge ainsi que le fishing game sont disponibles sur le gitlab : https://gitlab.inria.fr/acorn/unityotree_cpr_fishgame

La première étape consiste donc à télécharger/cloner les fichiers en local sur votre machine puis d’installer otree-core-master.

Le dossier OtreeCore/otree-core-master contient une version modifiée d’Otree. Il s’agit d’une version open source d’Otree modifiée. Cette dernière possède des fonctions supplémentaires nécessaires au bridge Otree-Unity. Cette installation est donc nécessaire même dans le cas où vous utilisez déjà une autre version de Otree.

Pour installer Otree ouvrez votre invite de commande, rendez vous dans le dossier otree-core-master et entrez la commande “pip install -e .”. Cette commande va lancer l’installation à l’endroit où se trouve votre dossier.

Note :

vous pouvez taper “cmd” dans la barre d’adresse du dossier pour ouvrir directement votre invite de commande dans ce dossier (voir le gif suivant).

Une fois la commande entrée, vous pouvez vérifier que Otree est bien installé en l’utilisant pour lancer le projet Otree en local. Pour cela ouvrez votre invite de commande dans le dossier zzv-new_project et tapez “otree devserver”.

Si vous n’avez pas de message d’erreur, l’installation est donc réussie. Vous pouvez alors ouvrir votre navigateur, vous rendre à l’adresse suivante : http://localhost:8000 et arriver sur cette interface :

Il s’agit ici d’une version en local pour tester l’installation de OtreeCore. Pour que votre projet soit accessible par d’autres machines (Meta Quest 3 en l’occurrence), il est nécessaire de le déployer sur un serveur.

2.2 - Déploiement sur serveur :

Le déploiement sur serveur permet à votre projet Otree d’être accessible sur n’importe qu’elle machine via l’adresse url du serveur. Ainsi les casques Meta Quest 3 pourront se connecter à ce serveur et échanger des données avec le projet Otree.

Pour cela la solution que nous utilisons est le déploiement d’un serveur Ubuntu Linux avec PostgreSQL comme système de gestion de base de données. Cette méthode est décrite dans la documentation Otree : https://otree.readthedocs.io/en/latest/server/ubuntu.html.

Aussi, Otree propose également la solution Heroku. Il s’agit d’une plateforme qui permet aux développeurs de créer des applications entièrement dans le cloud. Heroku gère alors le serveur web ainsi que les bases de données. La documentation suivante explique comment déployer votre projet Otree sur Heroku : https://otree.readthedocs.io/en/latest/server/heroku.html#heroku.

2.3 - Projet Unity :

Une fois le serveur Otree déployé il faut installer le projet Unity sur vos Meta Quest.

Ouvrez le projet Unity et rendez vous dans la scène “MetaQuestVersion”. Dans cette dernière se trouve un objet OtreeManager contenant un script OtreeManager.cs

Dans l’inspector à droite, entrez les données concernant votre serveur :

Branchez votre Meta Quest à votre PC, lancez le projet Unity et rendez vous dans File → Build Settings. Sélectionnez votre casque dans Run Device et lancez un build via Build And Run.

Note :

Si votre casque n’apparaît pas essayez les solutions suivantes :

  • Vérifiez que la plateforme sélectionnée est bien Android

Une fois votre projet build sur votre casque, ce dernier se lance automatiquement. Dans le cas contraire vous pouvez le retrouver dans Application → Sources Inconnues sous le nom “Unity-Otree Fishing Game”.

Note :

Si vous souhaitez tester directement la connexion à Otree en éditeur, vous pouvez utiliser la scène “DesktopVersion”. Cette dernière affiche uniquement l’interface du jeu et propose un bouton pour attraper un poisson. Elle peut aussi servir si vous souhaitez build le projet sur votre ordinateur plutôt que sur un casque.

2.4 - Utilisation de la passerelle Unity Otree :

Cette partie décrit les étapes à réaliser pour utiliser la passerelle via l’exemple du Fishing Game.

La première étape consiste à créer une nouvelle session Otree. Pour cela ouvrez votre navigateur et rendez vous à l’adresse url de votre serveur Otree. Sur cette page plusieurs options sont disponibles :

Dans le cas de notre exemple, nous avons seulement besoin de créer une session et de laisser notre serveur tourner durant l’expérience. Rendez-vous donc à l’onglet session. Créez alors votre session via l’option “create new session”, choisissez la configuration relative à votre projet Otree (dans notre cas “my_survey”) puis indiquez le nombre de participants de cette session. Dans notre exemple nous avons choisi 2 joueurs mais vous pouvez fixer le nombre de participants de votre choix. Notez que vous aurez besoin pour la suite de l’expérience d’un Meta Quest par joueur.

Vous pouvez également dans “Configure session” préciser le nombre de poissons que vous voulez au début de l’expérience dans votre bassin, par défaut 12.

Une fois votre session créée vous avez accès à son ID de session, dans notre cas 9w0ds7h2. Notez-le, ce dernier vous permettra de rejoindre votre session au début du jeu.

Désormais vous n’avez plus besoin de votre ordinateur, la suite se passe exclusivement sur le jeu dans votre Meta Quest.

Pour chaque participant, lancez une instance du jeu sur un Meta Quest. Une fois le jeu lancé vous arrivez sur une scène en réalité augmentée avec un bassin en 3D. Au centre de ce bassin vous avez un panneau de contrôle. Ce dernier vous permettra de rejoindre la session Otree précédemment créée.

Dans un premier temps, indiquez l’adresse de votre serveur dans la barre de texte et cliquez sur l’icône de flèche (si vous avez bien indiqué l’adresse de votre serveur dans les paramètres OtreeManager, cette dernière s’affiche par défaut).

Si l’adresse est valide, vous arrivez sur une nouvelle fenêtre. Votre application Unity est désormais connectée à votre serveur Otree.

L’étape suivante consiste à rejoindre une session. Pour cela deux choix sont possibles. Soit entrer le code de session généré précédemment (dans cet exemple 9w0ds7h2), soit trouver sa session dans la liste à droite en vous servant du même code.

Vous pouvez alors cliquer sur l’icône de flèche dans le premier cas, ou directement sur la session dans le second cas.

Vous arrivez alors sur un écran d’attente indiquant le nombre de participants actuel et le nombre de participants nécessaires.

Pour passer à l’étape suivante, refaites le même processus sur votre second Meta Quest (ou plus si vous avez indiqué plus de participants à la création de votre session).

Une fois les deux joueurs connectés, le jeu commence. Des poissons apparaissent alors dans le bassin, vous pouvez chacun pêcher autant de poissons que vous le voulez, suivant les règles du jeu explicité précédemment. Le nombre de poissons pêché par chaque joueur ainsi que le nombre de poissons encore présents dans le bassin étant indiqué sur l’interface suivante.

Une fois le jeu fini, les joueurs peuvent quitter l’application. Ils seront automatiquement déconnectés de la session.

Note :

Il est possible de moduler la croissance des poissons dans le bassin avant de build le projet. Pour cela, dans la scène, allez dans CanvaController → MainCanvas → Fishing Panel.

Dans l’inspector sous le script FishingPanel se trouve deux paramètres :

  • Growth Rate : Taux de croissance de la population
  • Growth Delay : Intervalle de temps entre chaque croissance

Ainsi dans notre cas, la population de poissons est multipliée par 1.1 toutes les 1 secondes

2.5 - Récupération des données :

Pour consulter les résultats du jeu, cela se passe sur votre serveur Otree. Rendez vous à l’adresse de votre serveur et entrez l’url suivante : “http://adresseDeVotreServeur/ExportSessionWide/IdentifiantDeLaSession

En précisant l’adresse de votre serveur ainsi que l’ID de la session que vous souhaitez consulter (dans notre cas uhobjh8v).

Vous obtenez alors un fichier .csv contenant toutes les données de cette session et de ses participants.

Note :

Pour plus de lisibilité, vous pouvez séparer les données par colonne en vous servant des virgules. Par exemple sur Excel avec la suite d’action suivante : sélectionnez la première colonne → Données → Convertir → Délimité → Suivant → Virgule → Terminer.

On peut constater deux séries de données, une pour chaque participant. Les données qui nous intéressent le plus ici sont les suivantes.

Pour les participants :

Pour la session : (les deux lignes auront les mêmes informations, les deux participants étant dans la même session)

Nous avons ainsi accès à toutes les données relatives à notre jeu de pêche pour chaque participant. Si vous voulez ajouter de nouvelles données pour créer votre propre expérience, cela est expliqué dans la partie suivante dédiée au fonctionnement de la passerelle Unity-Otree.

3 - Fonctionnement de la passerelle Unity-Otree :

Cette partie explique le fonctionnement du bridge et comment créer sa propre expérience Otree-Unity à partir de ce dernier.

3.1 - Paramètres custom Otree :

Comme vu précédemment, le bridge permet à Unity d’avoir accès aux paramètres des sessions Otree, de les lire et les modifier. Seulement, cela est possible uniquement sur certains types de paramètres.

En effet, si vous regardez les données obtenues en fin d’expérience dans le fichier .csv, vous pouvez constater qu’elles sont triés selon leurs nom. En premier les “participant”, les “session” et enfin les “my_survey” (my_survey étant le nom du projet Otree utilisé dans cet exemple).

Les variables des participants ainsi que des sessions peuvent être lues et éditées. En revanche, les variables du projet (my_survey) ne peuvent qu’être lu.

Pour communiquer entre Unity et Otree nous utilisons donc uniquement les deux premiers types de variables.

Pour créer une nouvelle variable, cela se passe dans le projet Otree dans le fichier settings.py.

unityotree_cpr_fishgame\CPR_FishGame_OtreeProject\zzv-new_project\settings.py

Ce dernier contient divers paramètres du projet. Ceux qui nous intéressent ici sont les suivants :

Pour ajouter une nouvelle variable il faut donc l’insérer dans la liste à la suite des variables précédentes et relancer votre serveur.

Les variables de SESSION_FIELDS et PARTICIPANT_FIELDS ne sont pas accessibles via une interface contrairement à celles de SESSION_CONFIG_DEFAULTS. La partie suivante explique comment accéder à ces variables et les utiliser dans votre projet.

3.2 - Requêtes REST API :

Pour échanger entre Otree et Unity nous avons besoin d’une API (Application Programming Interface). Il s’agit d’une interface qui permet à des applications de communiquer entre elles. Dans notre cas Otree propose son propre système via des requêtes REST API. L’architecture REST (Representational State Transfer) est une architecture qui permet la communication entre des systèmes en se servant du protocole HTTP. C’est-à-dire via des adresses URL comme lors de la récupération des données à la fin de notre jeu.

Ainsi une série de requêtes possibles a été développée par Otree et est référencées dans la documentation suivante : https://otree.readthedocs.io/en/latest/misc/rest_api.html

Les requêtes sont séparées selon deux catégories : GET et POST. Une requête GET est une requête qui permet de consulter des informations tandis qu’une requête POST permet de modifier des informations. Chaque requête est identifiée par son endpoint, il s’agit de la suite de caractère présent après la composante /api/.

Dans le cas de notre projet nous utilisons par exemple le endpoint api/sessions/{code} pour consulter les données relatives à chaque session.

Seulement, les requêtes proposées par Otree ne sont pas suffisantes dans le cadre de notre projet. En effet il n’est pas possible de lire toutes les variables, c’est notamment le cas des variables participant qui peuvent seulement être modifiées. Nous avons donc créé de nouvelles requêtes. D’où la nécessité d’installer la version de Otree-Core disponible sur le gitlab.

Chaque requête est définie par une fonction accessible dans le code open source. Toutes ces fonctions sont réunies dans le fichier rest.py accessible l’adresse suivante : unityotree_cpr_fishgame\OtreeCore\otree-core-master\otree\views\rest.py

Ces dernières sont toutes structurées sous la forme suivante :

class RESTOTreeVersion(BaseRESTView):
    url_pattern = '/api/otree_version'

    def get(self):
        return JSONResponse(dict(version=otree.__version__))
[rest.py L103-107]

Il s’agit ici de la fonction permettant d’obtenir le numéro de version d’Otree. La variable “url_patern” correspond au endpoint qu’il faut entrer pour accéder à cette requête. La requête est donc la suivante : http://adresseDuServeur/api/otree_version

Quand nous parlerons d’une requête, cette dernière sera donc nommée selon son endpoint (ici /api/otree_version).

Dans le cadre de notre projet les requêtes utilisées sont les suivantes ainsi que leurs types :

Ainsi qu’une nouvelle requête créée dans le cadre du projet :

C’est donc l’ensemble de ces requêtes qui sont utilisés par notre programme Unity pour communiquer avec notre serveur. La méthode d’envoi et de réception des requêtes étant expliquée dans la partie suivante.

3.3 - Utilisation des requêtes par Unity :

Au début de la documentation des requêtes Otree, un setup type nous est proposé pour utiliser ses requêtes.

Seulement il s’agit ici de code en python, alors que Unity utilise du C#. Nous avons donc créé nos propres fonctions accessibles dans le fichier OtreeManager.cs de notre projet Unity unityotree_cpr_fishgame\CPR_FishGame_UnityProject\Assets\Script\OtreeManager.cs

Le fichier OtreeManager.cs est le cœur du projet, nous allons donc le présenter en détail. Les autres scripts y faisant souvent appel.

Les variables :

[SerializeField] public string serverURL;
[OtreeManager.cs L14]

URL du serveur sur lequel le projet Otree est hébergée.

[SerializeField] public string OtreeRestKey;
[OtreeManager.cs L16]

Clé d’identification Otree pour autoriser les requêtes.

[SerializeField] public string sessionName;
[OtreeManager.cs L18]

Filtre pour retrouver les sessions utilisant notre configuration Otree dans le serveur.

[SerializeField] public string session;
[OtreeManager.cs L20]

Identifiant de la session Otree à laquelle le joueur s’est connecté.

[SerializeField] public string participant;
[OtreeManager.cs L22]

Identifiant de participant Otree associé au joueur.

[SerializeField] public string sessionDatasList = new List<SessionData>();
[OtreeManager.cs L24]

Liste des SessionDatas existant. Un SessionData est un objet contenant les informations suivantes :

Il permet de garder en mémoire les sessions se trouvant actuellement sur le serveur ainsi que leurs propriétés.

[SerializeField] public string sessionParameters = new SessionParameters();
[OtreeManager.cs L26]

Le SessionParameters actuel. Il s’agit de données propres aux gameplay. Contrairement à SessionData qui conserve les paramètres globaux propres à Otree. Un SessionParameters contient donc les paramètres suivants :

[SerializeField] public string participantInSession = new List<ParticipantData>();
[OtreeManager.cs L28]

Les ParticpantDatas relatifs à chaque participant de la session actuelle. Un ParticipantData contient les informations suivantes :

Les fonctions GetRequest et PostRequest

Il s’agit des fonctions servant à communiquer avec le serveur et envoyer des requêtes. Comme leurs noms l’indiquent, la fonction GetRequest permet d’envoyer des requêtes de type GET tandis que PostRequest envoie des requêtes de type POST.

Dans un premier temps voici le script de GetRequest :

public IEnumerator GetRequest(string url, Action<string> callback)
{
    UnityWebRequest uwr = UnityWebRequest.Get(url);
    uwr.SetRequestHeader("otree-rest-key", OtreeRestKey);
    yield return uwr.SendWebRequest();
    if (uwr.isNetworkError)
    {
        Debug.Log("Error : " + uwr.error);
        callback("Error : " + uwr.error);
    }
    else
    {
        callback(uwr.downloadHandler.text);
    }
}
[OtreeManager.cs L76-90]

L’envoi de requête est effectué grâce à UnityWebRequest. Il s’agit d’une classe Unity permettant de gérer et d’envoyer des requêtes HTTP.

Ligne 78 : Déclaration d’une nouvelle requête nomé “uwr”. Cette requête est de type GET avec comme paramètre l’url de notre requête en string comme vu lors de la partie 3.2.

Ligne 79 : Ajout d’une clé de sécurité avec son nom suivi de la valeur de la clé.

Ligne 80 : Envoi de la requête via la méthode SendWebRequest. yeild return signifie que la suite du code reprendra qu’une fois que cette action sera terminée. En attendant, la fonction est en pause. En effet, ces fonctions sont des Coroutines, ce qui leur permet de fonctionner de façon asynchrone. Cela est dû au fait que l’envois et la réception de données ne sont pas instantanés et dépendent de plusieurs facteurs dont la qualité du réseau. Il est donc nécessaire de pouvoir temporiser le temps de terminer une requête.

En cas de réussite le résultat est alors obtenu via uwr.downloadHandler.text et stocké dans un callback.

Ainsi, si l’on souhaite par exemple utiliser GetRequest pour vérifier la version de Otree utilisée. Le code sera le suivant :

StartCoroutine(GetRequest("http://**.***.***.**/api/otree_version", result =>
{
    //code effectué seulement une fois la requête finie
    Debug.Log("Otree version : " + result); //résultat
}));
//code effectué directement après l'envois de la requete

Le Debug présent dans l’exemple ne s’effectue qu’une fois le callback de la fonction GetRequest envoyé (ligne 84 ou 88). Ce qui nous permet d’écrire du code en utilisant le résultat de la requête. En revanche le code à la suite de notre coroutine s’effectue immédiatement après que la coroutine ait été sollicitée. Ce dernier agit donc trop tôt pour pouvoir utiliser le résultat de notre requête et doit servir à d’autres usages.

La fonction PostRequest est sensiblement similaire :

public IEnumerator PostRequest(string url, string content, Action<string> callback)
{
    using UnityWebRequest uwr = UnityWebRequest.Post(url, content, "application/json");
    uwr.SetRequestHeader("otree-rest-key", OtreeRestKey);
    yield return uwr.SendWebRequest();

    if (uwr.result != UnityWebRequest.Result.Success)
    {
        Debug.Log("Error : " + uwr.error);
        callback("Error : " + uwr.error);
    }
    else
    {
        callback(uwr.downloadHandler.text);
    }
}
[OtreeManager.cs L93-108]

La différence étant que UnityWebRequest.Post demande deux paramètres supplémentaires. Il s’agit de la donnée à modifier et de son type. Dans notre cas le type sera toujours “application/json”. La donnée à modifier quant à elle doit être compréhensible en python et l’API d’Otree attend une syntaxe spécifique.

Cette syntaxe est donc la suivante : “{"vars": {"VotreDonnée": SaValeur}}”

Ainsi, si par exemple on souhaite dans le cadre de notre jeu faire passer le nombre de poissons dans le bassin à 99. Le code est le suivant :

StartCoroutine(PostRequest("http://**.***.***.**/api/session_vars/9w0ds7h2/", 
                           "{\"vars\": {\"actual_nb_of_fish\": 99}}", result => { }));

On rappelle que actual_nb_of_fish est une variable de session, le endpoint à utiliser est donc /api/session_vars/{code}.

Les autres fonctions :

Les autres fonctions de OtreeManagers.cs se servent de GetRequest pour récupérer les données sur le serveur et actualiser les différentes variables présentées précédemment.

GetSessionList :

public IEnumerator GetSessionList(Action<string> callback){...}
[OtreeManager.cs L111-161]

Utilise GetRequest associé au endpoint /api/sessions pour récupérer les données relatives aux sessions existantes. Ces données se trouvent sous la forme du fichier Json suivant :

[
    {
        "code": "uhobjh8v",
        "num_participants": 2,
        "created_at": 1731419126, 
        "label": "",
        "config_name": "my_survey",
        "session_wide_url": "http://52.143.162.75/join/mefujiho", 
        "admin_url": "http://52.143.162.75/SessionStartLinks/uhobjh8y"
    },
    {
        "code": "u3zl7b4y", 
        "num_participants": 3, 
        "created_at": 1732206079, 
        "Label": "",
        "config_name": "my_survey",
        "session_wide_url": "http://52.143.162.75/join/ganoluve",
        "admin_url": "http://52.143.162.75/SessionStartLinks/u3zl7b4y"
    },
    {
        "code": "54f486va",
        "num_participants": 1, 
        "created_at": 1732804359,
        "label": "",
        "config_name": "my_survey",
        "session_wide_url": "http://52.143.162.75/join/zuzikoge",
        "admin_url": "http://52.143.162.75/SessionStartLinks/54f486va"
    }
]

La fonction sépare alors les données et crée un nouveau SessionData pour chaque session existante qu’elle ajoute à la liste sessionDatasList présentée plus tôt.

GetSessionParticipants :

public IEnumerator GetSessionParticipants(string session, Action<string> callback){...}
[OtreeManager.cs L164-235]

Utilise GetRequest associé au endpoint /api/test/ pour récupérer les informations des participants relatif à une session précise. Ces données sont sous la forme du fichier Json suivant :

participant.id_in_session, participant.code, participant. label, participant._is_bot,[...] 
1, awr2gg36,,0,0,2,,,,,,, 0.0,, 99, tbmjrfo7,,,,,,my_survey, 1, 12, 0.0,,1,,0.0,,,1,1 
2,xlbg8uri,,0,0,2,,,,,,, 0.0,,,tbmjrfo7,,,,,,my_survey, 1, 12,0.0,,2,,0.0,,,1,1

Les données sont ensuite séparées selon les participants puis selon le type de données. Des ParticipantData sont ensuite créés à partir de ces données et sont ajoutés à la liste participantInSession. À noter que s’il y a déjà un ParticipantData avec cet identifiant dans la liste, ce dernier est actualisé à la place.

Un cas particulier de cette fonction contrairement à la précédente est le traitement de certaines variables. En effet c’est ici que nous retrouvons les variables custom “fish” et “taken” introduites dans la partie 3.1.

Comme expliquée précédemment, la variable “fish” correspond au nombre de poissons attrapés par le joueur. Cette dernière est un string coté Otree mais nous souhaitons l’associer au paramètres nbOfFish de l’objet ParticipantData qui est un int. La conversion de string à int peut se faire via des méthodes tel que int.Parse(“votreValeurTextuelle”).

Seulement nous avons expliqué précédemment que les variables custom n’ont pas de valeur par défaut. Donc tant que la variable n’a pas été modifiée via une requête POST, cette dernière n’a pas de valeur. Ainsi, une condition qui vérifie l’état de la variable a été ajoutée.

if(splitedLabels[j].Contains("participant.fish"))
{
    if (splitedParticipant[j] == null 
        || splitedParticipant[j] == "null" 
        || splitedParticipant[j] == "")
    {
        participant.nbOfFish = 0;
    }
    else
    {
        participant.nbOfFish = int.Parse(splitedParticipant[j]);
    }
}
[OtreeManager.cs L207-217]

La variable “taken” suit la même règle, excepté que nous souhaitons la faire correspondre à un booléen. La règle est donc la suivante :

if (splitedLabels[j].Contains("participant.taken"))
{
    if (splitedParticipant[j] == null 
        || splitedParticipant[j] == "null" 
        || splitedParticipant[j] == "" 
        || splitedParticipant[j] == "0")
    {
        participant.taken = false;
    }
    else
    {
        participant.taken = true;
    }
}
[OtreeManager.cs L195-206]

La variable taken est utilisée lors des connexion et déconnexion des joueurs. Ce n’est donc pas un élément de gameplay comme le nombre de poissons dans le bassin ou attrapé par les joueurs. C’est pour cela qu’elle n’a pas encore été présentée. Cela sera fait dans la partie 4.1.

GetSessionParameters :

public IEnumerator GetSessionParameters(string session, Action<string> callback){...}
[OtreeManager.cs L238-280]

Utilise GetRequest associé au endpoint /api/test/ pour récupérer les paramètres de session custom relatif à la session. Il s’agit donc du même fichier Json que précédemment mais cette fois ce sont les variables “initial_nb_of_fish” et “actual_nb_of_fish” qui sont recherchées. Dans le cas de “initial_nb_of_fish”, nous avions expliqué en partie 3.1 que cette variable possède une valeur par défaut. Il n’y a donc pas de cas particulier. En revanche ce n’est pas le cas de “actual_nb_of_fish”. La logique est donc la suivante :

if (splitedLabel[i].Contains("initial_nb_of_fish"))
{
    sessionParameters.initialNbOfFish = int.Parse(splitedParticipant[i]);
}
if (splitedLabel[i].Contains("actual_nb_of_fish"))
{
    if (splitedParticipant[i] == "")
    {
        sessionParameters.actualNbOfFish = sessionParameters.initialNbOfFish;
    }
    else
    {
        string normalizedValue = splitedParticipant[i].Replace('.',',');
        sessionParameters.actualNbOfFish = float.Parse(normalizedValue);
    }
}
[OtreeManager.cs L259-275]

À noter que le paramètre actualNbOfFish est un float et non un int. Cela est dû à un élément de gameplay. En effet, pour faire croître le nombre de poissons dans le bassin, cette valeur est multipliée au fil du temps. Elle peut donc être un nombre décimal. Un nouveau poisson apparaît seulement lorsque actualNbOfFish passe par nombre entier. Or les valeurs décimales dans le fichier Json sont représentée par des points (.) alors que les nombres décimaux en C# sont représentées par des virgules(,). Une conversion avec la fonction Replace doit donc se faire dans ce genre de situation.

Nous avons donc 2 fonctions permettant d’envoyer des requêtes, 3 autres fonctions exploitant ces requêtes pour récupérer des données ainsi qu’une série de variables pour stocker et travailler avec ces données. La base de notre bridge est donc complète. Nous pouvons désormais nous servir de ces éléments pour développer la suite de notre Jeu.

4 - Fonctionnement du jeu :

Cette partie sera séparée en trois sous parties suivantes :

La partie consacrée au jeu de pêche explicite le développement du gameplay et se sert des variables vues durant les parties précédentes (poissons, bassin, ect…). Elle est donc spécifique à ce proof of concept. En revanche, la connexion et la déconnexion peuvent servir dans d’autres projets. Le processus de connexion/déconnexion des joueurs étant nécessaire quel que soit votre usage du bridge Unity-Otree.

4.1 - La connexion :

La connexion des participants au serveur se fait via le script ConnectionPanel.cs. Il est rattaché à l’objet ConnectionPanel dans la scène à l’endroit suivant : CanvaController → MainCanvas → ConnectionPanel et le fichier est accessible dans l’arborescence à l’endroit suivant : unityotree_cpr_fishgame\CPR_FishGame_UnityProject\Assets\Script\ConnectionPanel.cs

Ce script est lié aux différents panneaux de connexion vue dans la partie 2.4.

Après que l’utilisateur ait entré l’adresse URL, ce dernier clique sur le bouton à droite ce qui lance la fonction OnServerEnterButton.

public void OnServerEnterButton()[...]
[ConnectionPanel.cs L49-91]

La fonction va récupérer l’adresse url et l’attribuer à la variable “serverURL” de OtreeManager.cs vue précédemment. Désormais toutes les requêtes seront donc effectuées à cette adresse.

Note :

Pour accéder aux variables et fonctions de OtreeManager.cs, une variable statique de type OtreeManager à été créée dans ce même script.

public static OtreeManager instance;
[OtreeManager.cs L11]
void Awake()
{
    instance = this;
}
[OtreeManager.cs L34-37]

Ainsi tous les autres scripts du projet peuvent faire appel à OtreeManager de la façon suivante :

OtreeManager.instance.NomDeVariable

OtreeManager.instance.NomDeFonction()

C’est le cas ici ou le script ConnectionPanel.cs accède à la variable de OtreeManager.cs

OtreeManager.instance.serverURL = serverURLText.text;
[ConnectionPanel.cs L58]

Une fois l’adresse url stockée, la méthode GetSessionList va être utilisée pour récupérer les sessions existantes à cette adresse.

StartCoroutine(OtreeManager.instance.GetSessionList(onComplete => ...));
[ConnectionPanel.cs L62-90]

Puis notre code va se servir de sessionDatatsList actualisée grâce à GetSessionList pour consulter les données des sessions.

foreach(SessionData session in OtreeManager.instance.sessionDatasList)...
[ConnectionPanel.cs L66-90]

À partir de cette liste nous allons alors instancier des SessionButton. Il s’agit de Prefab ayant l’apparence suivante :

Ce sont des boutons cliquables avec comme indication de nom d’une session et le nombre de places restantes. Le but est d’en créer un pour chaque session. L’identifiant (uhobjh8v) est obtenu via SessionData.code et le nombre max de participant (2) via SessionData.num_participants. En revanche pour définir le nombre de places restantes nous nous servons de la variable “taken”.

La variable “taken”

Contrairement aux autres variables custom introduites précédemment, la variable “taken” ne sert pas au gameplay de jeu. Cette dernière sert à répartir les joueurs selon les identifiants des participants disponibles.

En effet, comme nous l’avons déjà vu, à la création d’une session sur Otree, un nombre de participants est défini. Et pour chaque participant une série de données le concernant est créée. C’est ce qu’on constate quand on récupère les données, chaque ligne étant associé à un participant :

L’un des objectifs du bridge Otree-Unity est donc d’associer les nouveaux joueurs côté Unity à ces séries de données. Pour cela on se sert de la données Participant.code qui s’avère être un identifiant unique (dans l’exemple au-dessus etx39496 et yeybgtjg). Ainsi à la connexion d’un nouveau joueur, on lui attribue l’un des identifiants existants. Ce dernier est stocké pour que quand ce joueur effectue des action (pêcher un poisson, ect..) on puisse retrouver la ligne de données qui lui correspond coté Otree afin de l’actualiser.

Chaque joueur ne modifiant que sa ligne de données et jamais celles des autres. Les autres lignes ne servant qu’à être consulté (par exemple pour afficher le nombre de poisson pêché par les autres joueurs)

Seulement, comment être sûr à l’arrivée d’un nouveau joueur qu’on l’associe bien avec une ligne vierge ? C’est l’objectif de la variable “taken”. Comme nous l’avons vu dans la partie précédente, lors de la récupération des données la variable taken suit la règle suivante :

Ainsi si taken = false alors cette ligne n’est pas encore associée à un autre joueur et peut être utilisée. Si taken = true alors cette ligne est déjà prise.

Pour revenir à notre SessionButton, Il est donc possible de connaître le nombre de joueurs déjà dans la session en comptant le nombre de ParticipantData ayant leur variable taken = true.

StartCoroutine(OtreeManager.instance.GetSessionParticipants(session.code, onComplete => 
{
    foreach(ParticipantData participant in OtreeManager.instance.participantInSession)
    {
        if (participant.taken)
        {
            places++;
            sessionButtonInstance.GetComponent<SessionButton>().SetupButtonUI(session.code, places, session.num_participants);
        }
    }
}));
[ConnectionPanel.cs L74-86]

Une fois nos SessionButton créé et à jour nous pouvons donc cliquer sur celui de notre choix pour rejoindre la session correspondante.

Ici un exemple avec 2 sessions et aucun joueur connecté (une à 2 participants et l’autre à 1 participant).

Cliquer sur l’un de ces boutons lance alors la deuxième étape de la connexion via la fonction OnConnectionButton.

public void OnConnectionButton(){...}
[ConnectionPanel.cs L122-160]

Cette dernière vérifie que la session en question existe toujours. En effet, du temps peut s’être écoulé entre la connexion au serveur et votre choix de session. Temps durant lequel la session peut être par exemple supprimée. Puis lance la fonction AttributeParticpantID qui, comme son nom l’indique, va attribuer l’un des identifiant de cette session au joueur.

private void AttributeParticipantID(){...}
[ConnectionPanel.cs L176-202]

La fonction va dans un premier temps consulter les variables “taken” des participants de cette session. S’il n’y en a aucune de disponible, la fonction renvoie un message d’erreur et s’arrête. Dans le cas contraire, elle récupère le dernier ParticipantData ayant une variable taken = false.

for (int i = 0; i < OtreeManager.instance.participantInSession.Count; i++)
{
    if (!OtreeManager.instance.participantInSession[i].taken)
    {
        participantAviable = OtreeManager.instance.participantInSession[i];
    }
}
if(participantAviable == null)
{
    ShowErrorPanel("No space available");
}
[ConnectionPanel.cs L179-189]

La fonction peut alors effectuer une requête POST pour actualiser la variable taken coté Otree de ce participant et lui attribuer une valeur. Ainsi lors des futures GetSessionParticipants la variable taken de ce participant sera sur true et les données correspondantes ne seront pas attribuées à un autre joueur. Puis en callback de cette requête les informations du joueur sont mises à jour.

StartCoroutine(OtreeManager.instance.PostRequest(OtreeManager.instance.serverURL 
    + "/api/participant_vars/" + participantAviable.code 
    + "/", "{\"vars\": {\"taken\": 77}}", onComplete =>
{
    OtreeManager.instance.session = targetSession;
    OtreeManager.instance.participant = participantAviable.code;
    onParametersSet?.Invoke(targetSession, participantAviable.code);
    onConnectionDone?.Invoke();
}));
[ConnectionPanel.cs L193-200]

La connexion est maintenant terminée. Ce joueur est associé à un participant Otree dans une session précise du serveur.

L’étape suivante consiste donc à attendre les autres participants. Cela se fait via le script WaitingRoomPanel.cs. Il est rattaché à l’objet WaitingRoom dans la scène à l’endroit suivant : CanvaController → MainCanvas → WaitingRoom Et le fichier est accessible dans l’arborescence à l’endroit suivant : unityotree_cpr_fishgame\CPR_FishGame_UnityProject\Assets\Script\WaitingRoomPanel.cs

Il s’agit d’un script qui envoie en boucle une requête pour actualiser les informations des participants et qui s’arrête une fois que tous les participants sont occupés par des joueurs. C’est à dire quand taken = true pour les données que chaque participant.

Une fois les conditions remplies le panneau d’attente se ferme et le jeu peut commencer.

4.2 - La déconnexion :

Bien que plus simple, la déconnexion est une étape aussi importante que la connexion. En effet, il doit être possible de déconnecter un joueur si celui-ci quitte le jeu au cas où un autre voudrait prendre sa place.

La gestion de la déconnexion est effectuée par le script QuitManager.cs. Il est rattaché à l’objet QuitManager à la base de la scène. Le fichier est accessible dans l’arborescence à l’endroit suivant : unityotree_cpr_fishgame\CPR_FishGame_UnityProject\Assets\Script\QuitManager.cs

Dans ce script se trouve la fonction QuitFunction. Cette dernière a pour rôle d’envoyer une requête POST qui va changer la valeur de la variable “taken” coté Otree.

StartCoroutine(otreeManager.PostRequest(otreeManager.serverURL + "/api/participant_vars/" 
    + otreeManager.participant + "/", "{\"vars\": {\"taken\": 0}}", onComplete =>
{
    allowQuitting = true;
    Application.Quit();
}));
[QuitManager.cs L39-43]

Ainsi les données du Participant correspondant à cette variable “taken” seront de nouveau libres d’être associé à un autre joueur.

Pour lancer la fonction QuitFunction au bon moment nous utilisons OnApplicationQuit, il s’agit d’une fonction d’unity qui se déclenche quand l’application cherche à se fermer.

void OnApplicationQuit()
{
    QuitFunction();
}
[QuitManager.cs L23-26]

Nous utilisons également Application.CancelQuit afin d’empêcher la fermeture de l’application tant que la requête visant à changer la valeur de “taken” n’est pas finie.

if (!allowQuitting)
{
    Application.CancelQuit();
}
[QuitManager.cs L52-55]

Ainsi à la fermeture de l’application, cette dernière est mise en suspens le temps d’envoyer une requête. Puis l’application se ferme pour de bon.

À noter que dans le cas d’une application sur Meta Quest, la fonction OnApplicationQuit ne suffit pas. En effet, le déclencheur n’est pas reconnu. Nous avons donc dû utiliser OnApplicationPause, cette fonction se déclenche quand on retourne sur le menu de notre Meta Quest.

void OnApplicationPause()
{
#if UNITY_ANDROID
    QuitFunction();
#endif
}
[QuitManager.cs L15-20]

Pour un usage sur ordinateur, elle se déclenche quand l’utilisateur clique en dehors de la fenêtre de l’application. Pour éviter cela, nous avons ajouté une condition #if UNITY_ANDROID pour qu’elle ne prenne effet que sur Android, ce qui est le cas d’un Meta Quest.

Nous avons donc notre système de connexion et de déconnexion. Il ne reste plus que le développement du jeu de pêche lui-même.

4.3 - Le jeu de pêche :

Les scripts concernant les gameplay du jeu de pêche sont les suivants :

L’évolution du bassin :

Lorsque le panneau d’attente est fini et que la connexion de tous les joueurs est faite, WaitingRoomPanel.cs envoi un event réceptionné par FishingPanel.cs et FishSpawner.cs. Ces deux scripts vont alors entrer en action.

Le script FishingPanel.cs va alors mettre en place les différents paramètres de jeu via ShowPanel qui va appeler les fonction suivantes :

public void SetupActualNbOfFish(){...}
[FishingPanel.cs L63-69]

SetupActualNbOfFish pour faire correspondre côté serveur le nombre de poisson actuellement dans le bassin avec le nombre de poisson initialement prévu dans les paramètres de session.

public void SetupPlayerNbOfFish(){...}
[FishingPanel.cs L72-81]

SetupPlayersNbOfFish qui pour chaque participant va instancier un PlayerDataPrefab. Il s’agit d’éléments d’interface affichant les informations relatives à un participant.

public void DataUpdater(){...}
[FishingPanel.cs L155-170]

DataUpdater qui va faire deux requêtes pour mettre à jour le jeu vis à vis des paramètres de session et des participants côté serveur. Puis une fois ces requêtes finies, va se rappeler lui-même. En effet cette fonction a pour but d’actualiser en permanence les données de notre jeu pour qu’elles puissent tenir compte des modifications qui seront apportées sur les données du serveur par les autres joueurs. En relançant la fonction seulement une fois les précédentes requêtes finies, on évite une surcharge du serveur. Enfin, DataUpdater appelle une fois GrowthFishFunction après avoir fini la première actualisation des données.

public void GrowthFishFunction(){...}

public IEnumerator GrowthFishCoroutine(){...}
[FishingPanel.cs L118-152]

GrowthFishFunction lance la Coroutine GrowthFishCoroutine servant à faire croître le nombre de poissons dans le bassin et actualiser cette donnée sur le serveur. Seulement si tous les joueurs lancent cette coroutine alors le nombre de poissons va croître encore plus vite, proportionnellement au nombre de joueurs (par exemple pour 3 joueurs le nombre de poissons va croître 3 fois trop vite).

La solution est donc de choisir un seul joueur qui lancera cette fonction. Pour cela nous décidons de nous baser sur l’identifiant des participants dans la session. Ainsi, nous faisons une vérification permettant uniquement au premier participant de la session de lancer la coroutine (ce choix est arbitraire, n’importe quel participant pourrait le faire tant qu’il est le seul à lancer cette fonction).

//check if actual player is the first particpant
foreach (ParticipantData participant in otreeManager.participantInSession)
{
    if(participant.id_in_session == 1 && participant.code == otreeManager.participant)
    {
        StartCoroutine(GrowthFishCoroutine());
    }
}
[FishingPanel.cs L120-127]

La fonction GrowthFishCoroutine attend alors le délai indiqué en paramètres puis multiplie le nombre de poissons dans le bassin par le taux de croissance. Tout en respectant les deux règles suivantes :

Enfin la fonction actualise les données côté serveur pour que les itérations du jeu des autres joueurs puissent avoir accès au nouveau nombre de poissons. La fonction se rappelle ensuite elle-même, pour faire croître à nouveau la population au prochain délai.

Il s’agit jusqu’à présent d’actualisation et de transformation de données (excepté la fonction SetupPlayersNbOfFish). En parallèle le script FishSpawner.cs contrôle l’aspect visuel :

À l’instar de FishingPanel.cs, il est initié à la réception de l’event de WaitingRoomPanel.cs, lançant alors la fonction StartGraphicVersion.

public void StartGraphicVersion(){...}
[FishSpawner.cs L61-66]

Cette dernière initie des variables locales et récupère OtreeManager pour la suite du processus, permettant alors au code dans la fonction Update de s’effectuer. La fonction Update compare donc le nombre effectif de poisson dans le bassin au nombre visé par la valeur provenant de OtreeManager selon la règle suivante :

L’instanciation de nouveaux poissons se fait via la fonction SpawnFishs.

public void SpawnFishs(int nbToSpawn){...}
[FishSpawner.cs L69-82]

Il existe 4 prefab de poissons possibles, l’un d’entre eux est choisi au hasard puis instancié à une position aléatoire dans le bassin avec une orientation aléatoire. Puis l’objet issu de ce prefab est ajouté à la liste spawnedFish, recensant les poissons actuellement dans le bassin. Le processus est répété jusqu’à atteindre le nombre de poissons voulu. Aussi, chaque prefab possède un script FishMovment.cs contrôlant le comportement du poisson une fois instancié.

La suppression de poissons quant à elle se fait via la fonction RemoveFishRandomly.

public void RemoveFishRandomly(int nbToRemove){...}
[FishSpawner.cs L85-98]

Pour cela, on pioche aléatoirement dans la liste de poissons présentée précédemment. Puis on retire le poisson sélectionné de cette liste avant de le supprimer via la fonction Destroy. Le processus est répété jusqu’à atteindre le nombre de poissons voulu.

Il s’agit donc du processus du fonctionnement du bassin. Maintenant nous allons voir celui qui intervient quand le joueur pêche un poisson.

L’action de pêche

Pour pêcher un poisson, il est nécessaire de le cibler avec le viseur introduit dans la partie 1.3.

Il est associé à Raycast.cs, un script attaché au contrôleur (XR Interaction → XR Origin (XR Rig) → Camera Offset → Right Controller → Raycast). En calculant le point de collision entre le rayon du contrôleur et le fond du bassin, ce script permet alors à la cible de suivre nos mouvements.

Le viseur à comme script MarkerManager.cs ainsi qu’un CaspuleCollider. Ce dernier lui permet de stocker dans une liste les poissons avec lesquels elle rentre en contact. Les règles suivantes sont alors appliquées :

Note :

La fonction ActivateOutline du script FishMovment.cs est aussi appelée permettant de mettre en surbrillance le poisson attrapable.

Pour attraper un poisson, il faut alors presser la gâchette. Cette action est détectée via la fonction InputToggle.

private void InputToggle(InputAction.CallbackContext callbackContext){...}
[MarkerManager.cs L46-52]

Qui appelle à son tour la fonction Catch.

private void Catch(){...}
[MarkerManager.cs L75-86]

Cette dernière consulte la liste des poissons actuellement sur la cible et pour chacun appelle la méthode Catched de leurs scripts FishMovment.cs respectif. Aussi, la fonction envoie un event reçus par FishingPanel.cs et lance la méthode TakeSomeFish.

private void TakeSomeFish(int quantity){...}
[FishingPanel.cs L99-115]

Cette dernière a pour but d’actualiser le nombre de poissons dans le bassin côté serveur mais aussi d’actualiser le nombre de poissons attrapé par notre participant côté serveur.

En parallèle la fonction Catched de FishMovment.cs est en cours.

public void Catched(){...}
[FishingPanel.cs L49-58]

La fonction fait disparaître le poisson associé et fait apparaître l’effet de score visible lors d’une capture.

Ainsi nous avons donc l’ensemble de notre processus. L’état des bassins de chaque joueur s’actualise en se basant sur les données du serveur. Tandis que chaque prise des joueurs actualise les données du participant le concernant sur le serveur. Permettant à chaque joueur de pêcher dans le même bassin et de constater les actions des autres.

1 - Conclusion :

À travers ce document nous avons détaillé l’ensemble du projet Tulibee. Ainsi notre proof of concept de jeu de pêche nous a permis de présenter le fonctionnement de cette passerelle et comment l’installer. En se servant de cet exemple comme base il est alors possible d’étendre cette passerelle à d’autres projets et de développer des expériences collaboratives basées sur Otree et Unity.