Comment créer une geo game app avec Kuzzle

English version

 

L'écosystème du jeu vidéo mobile a connu un nouveau tournant ces dernières années. Pokemon Go en 2018 ou plus récemment Harry Potter Wizards Unite sont des applications basées sur la localisation qui nous proposent de capturer et collectionner diverses créatures partout dans le monde (même si ces dernières préfèrent surtout les grandes villes).

Comme tout bon développeur qui se respecte, on a eu bien envie chez Kuzzle de bricoler notre propre version d’une appli GO-like. Pourquoi ne pas créer (avec Kuzzle) la nouvelle appli pour capturer et collectionner les langages de programmation ?

 

Pokemon Go, Wizards Unite... bientôt Kuzzle Go ?

 

 

Nous allons construire notre application en utilisant Flutter pour l’application mobile et Kuzzle pour facilement mettre en place un back-end robuste.

 

Mise en place des bases

 

L’architecture de notre projet se découpe comme tel : un dossier app/ où l’on mettra notre application Flutter et un dossier backend/ dans lequel nous mettrons notre serveur Kuzzle.

 

Vous pouvez suivre ce guide pour initialiser l’appli Flutter dans le dossier app/.

 

Pour le backend, nous allons utiliser le Kuzzle core plugin boilerplate, il suffit de cloner le repos dans notre dossier et de le nommer backend/.

 

Let’s get into it.

 

Afficher la carte google map

 

Première étape: afficher la carte de l’endroit où l’on se trouve réellement !

 

Tout d'abord, le widget principal de notre application ("App") contient un "DefaultTabController", c’est un widget importé depuis la librairie de Material Design de Flutter. Il n’y a pour l’instant qu’une seul tab qui est notre map:

 

class _AppState extends State<App> {

 @override
 Widget build(BuildContext context) {
  
   return MaterialApp(
     title: 'Kuzzle GO',
     theme: ThemeData(
       primarySwatch: Colors.blue,
     ),
     home: DefaultTabController(
       length: 1,
       child: Scaffold(
         appBar: AppBar(
           bottom: TabBar(
             tabs: [
               Tab(text: 'Map', icon: Icon(Icons.map)),
             ],
           ),
         ),
         body: TabBarView(
           children: [
             GoMap(),
           ],
         ),
       ),
     ),
   );
 }
}

 

Il ne reste plus qu'à créer notre widget "GoMap" qui contient le widget Google Map officiel de Flutter (il faudra avant ça créer et importer votre API key pour que le widget fonctionne). Nous utilisons aussi le package geolocator pour régulièrement récupérer la géolocalisation et bouger la caméra de la map en fonction :

 

class _MapState extends State<Map> with AutomaticKeepAliveClientMixin {
 // tells flutter to not destroy
 // this widget on tabs change
 bool wantKeepAlive = true;

 CameraPosition _initialPosition = CameraPosition(target: LatLng(0, 0), zoom: 18.8);
 GoogleMapController _controller;
  _synchronize() async {
   Position position = await Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
  
   _controller.animateCamera(
     CameraUpdate.newLatLng(LatLng(position.latitude, position.longitude)),
   );

   sleep(new Duration(seconds: 1));
   this._synchronize();
 }

 _onMapCreated(GoogleMapController controller) async {
   _controller = controller;
   this._synchronize();
 }

 @override
 Widget build(BuildContext context) {
   super.build(context);

   return Scaffold(
     body: Stack(
       children: <Widget>[
         GoogleMap(   
           onMapCreated: _onMapCreated,
           initialCameraPosition: _initialPosition,
           myLocationEnabled: true,
           mapType: MapType.terrain,
           rotateGesturesEnabled: false,
           scrollGesturesEnabled: false,
           tiltGesturesEnabled: false,
           zoomGesturesEnabled: false
         ),
       ],
     )
   );
 }
}

 

Et voilà le résultat ! Une carte… vide.

 

carte-vide

 

Mise en place des collectables

 

Nous allons maintenant passer du côté de Kuzzle pour mettre en place nos langages, leur génération dans le monde, etc.

 

Pour cela, nous allons utiliser deux collections: "entities" et "collectable".

 

"entities": c’est ici que nous mettrons les caractéristiques des langages que nous pourrons retrouver dans le jeu (nom, description, image, etc…).

 

"collectable": c’est dans cette collection que seront placées toutes les instances d’un langage dans le monde. Un collectable est une instance d’une entité que l’on peut attraper dans le jeu.

 

Nous allons donc commencer par créer le mapping de ces deux collections en éditant le fichier server/fixtures/default-mappings.json:

 

{
 "world": {
   "entities": {
     "dynamic": "strict",
     "properties": {
       "name": {
         "type": "keyword"
       },
       "description": {
         "type": "text"
       },
       "image": {
         "type": "text"
       }
     }
   },
   "collectable": {
     "dynamic": "strict",
     "properties": {
       "location": {
         "type": "geo_point"
       },
       "entity": {
         "type": "keyword"
       }
     }
   }
 }
}

 

La collection "collectable" se remplira automatiquement mais nous pouvons déjà éditer dans le fichier server/fixtures/default-fixtures.json la collection "entities" avec les langages que nous voulons voir apparaître dans le jeu:

 

{
 "world": {
   "entities": [
     { "index": { "_id": "javascript" }},
     {
       "name": "JavaScript",
       "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/JavaScript-logo.png/600px-JavaScript-logo.png",
       "description": "JavaScript, often abbreviated as JS, is a high-level, interpreted programming language that conforms to the ECMAScript specification. JavaScript has curly-bracket syntax, dynamic typing, prototype-based object-orientation, and first-class functions."
     },
     { "index": { "_id": "c" }},
     {
       "name": "C",
       "image": "https://png.icons8.com/color/1600/c-programming",
       "description": "C is a general-purpose, procedural computer programming language supporting structured programming, lexical variable scope, and recursion, while a static type system prevents unintended operations."
     },
     { "index": { "_id": "cpp" }},
     {
       "name": "C++",
       "image": "https://is2.4chan.org/g/1562645051087.png",
       "description": "C++ is a general-purpose programming language created by Bjarne Stroustrup as an extension of the C programming language, or 'C with Classes'. The language has expanded significantly over time, and modern C++ has object-oriented, generic, and functional features in addition to facilities for low-level memory manipulation."
     },
     { "index": { "_id": "haskell" }},
     {
       "name": "Haskell",
       "image": "http://dev.stephendiehl.com/hask/img/haskell_logo.svg",
       "description": "Haskell is a statically typed, purely functional programming language with type inference and lazy evaluation. Type classes, which enable type-safe operator overloading, originated in Haskell."
     },
     { "index": { "_id": "java" }},
     {
       "name": "Java",
       "image": "https://www.stickpng.com/assets/images/58480979cef1014c0b5e4901.png",
       "description": "Java is a general-purpose programming language that is class-based, object-oriented (although not a pure OO language, as it contains primitive types), and designed to have as few implementation dependencies as possible."
     }
   ]
 }
}

 

Vous pouvez remplir ce fichier avec les entités que vous voulez (d’autres langages, des voitures, des fruits, des personnages célèbres…).

 

Instantiation des entités

 

Pour choisir ou instancier des entités dans notre monde, nous allons choisir la technique suivante : d’abord, chaque client de l’application enregistrera sa géolocalisation régulièrement auprès du backend. Ensuite, on vérifie s’il y a des collectables dans un périmètre arbitraire autour du joueur. S’il n’y en a aucun, on peut alors instancier un nombre X d’entités choisi aléatoirement.

 

Faisons le code de l’action "register" qui sera appelé par les différents clients pour enregistrer leurs positions :

 

 async register (request) {
   const lat = this.floatParam(request, 'latitude');
   const lon = this.floatParam(request, 'longitude');

   const results = await this.sdk.document.search(
     'world',
     'collectable',
     {
       query: {
         bool: {
           must: { match_all: {} },
           filter: {
             geo_distance: {
               distance: '100m',
               location: { lat, lon }
             }
           }
         }
       }
     }
   );

   if (results.total) {
     return true;
   }

   const { hits: entities } = await this.sdk.document.search(
     'world',
     'entities',
     { query: {} }
   );

   const P = {
     latitude: lat,
     longitude: lon
   };
  
   const newCollectable = Array(20)
     .fill(null)
     .map(() => ({
       body: {
         entity: entities[Math.floor(Math.random() * entities.length)]._id,
         location: mapKeys(randomLocation.randomCirclePoint(P, 100), (_, k) => k.substr(0, 3))
       }
     }));

   await this.sdk.document.mCreate(
     'world',
     'collectable',
     newCollectable
   );

   return true;
 }

 

Il faut ensuite bien setup cette action dans le plugin pour que l’on puisse l’appeler depuis le client !

 

Notre travail coté backend est déjà fini ! On peut maintenant retourner sur notre application mobile (c’est court avec Kuzzle :p).

 

Connection à Kuzzle & chargement des données

 

Coté application mobile, il nous faut maintenant nous connecter à notre backend et ensuite charger les données dont on aura besoin pour le fonctionnement de notre application : les images de chaques entité (nos langages) et les "shared_preferences" dans lesquelles nous allons stocker les entités attrapées par le joueur.

 

Tout d’abord, nous aurons besoin de nous connecter à Kuzzle via le SDK en Dart. Une fois installé et importé, nous pouvons nous connecter au backend dans la fonction main de notre application :

 

final kuzzle = Kuzzle(
 WebSocketProtocol('10.35.251.242', port: 7512),
 offlineMode: OfflineMode.auto,
);

void main() {
 kuzzle.on('error', (e) {
   debugPrint(e);
 });

 kuzzle.connect();
 return runApp(App());
}

 

On modifie ensuite notre widget App qui ressemble maintenant à ça :

 

class _AppState extends State<App> {
 _AppState();

 Map<String, dynamic> _entities;
 SharedPreferences _prefs;
 bool _isLoading = true;

 loadData() async {
   final Map<String, dynamic> entities = Map();

   final results = await kuzzle.document.search(
     'world',
     'entities',
     size: 100,
   );

   for (var entity in results.hits) {
     entities[entity['_id']] = entity['_source'];
     entities[entity['_id']]['asset'] = await loadNetworkImage(entity['_source']['image'], width: 100);
   }

   SharedPreferences prefs = await SharedPreferences.getInstance();

   setState(() {
     _prefs = prefs;
     _entities = entities;
     _isLoading = false;
   });
 }

 @override
 void initState() {
   super.initState();
   loadData();
 }

 @override
 Widget build(BuildContext context) {
   if (_isLoading) {
     return Container(
       child: Center(
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.center,
           children: <Widget>[
             SizedBox(
               child: CircularProgressIndicator(),
               height: 200.0,
               width: 200.0,
             ),
           ],
         ),
       ),
     );
   }

   return MaterialApp(
     title: 'Kuzzle GO',
     theme: ThemeData(
       primarySwatch: Colors.blue,
     ),
     home: DefaultTabController(
       length: 1,
       child: Scaffold(
         appBar: AppBar(
           bottom: TabBar(
             tabs: [
               Tab(text: 'Map', icon: Icon(Icons.map))
             ],
           ),
         ),
         body: TabBarView(
           children: [
             GoMap(entities: _entities, prefs: _prefs)
           ],
         ),
       ),
     ),
   );
 }
}

 

On peut voir dans ce code l’utilisation de la fonction "loadNetworkImage" qui est une fonction helper pour télécharger une image et la recadrer dans une taille précise :

 

Future<dynamic> loadNetworkImage(String url, { int width: -1, int height: -1 }) async {
 final NetworkImage image = NetworkImage(url);
 final NetworkImage val = await image.obtainKey(ImageConfiguration());
 final ImageStreamCompleter load = image.load(val);
 final completer = Completer();

 load.addListener(ImageStreamListener((ImageInfo info, bool syncCall) async {
   final res = await getBytesFromCanvas(info.image, width: width, height: height);
   completer.complete(BitmapDescriptor.fromBytes(res));
 }));

 return completer.future;
}

Future<Uint8List> getBytesFromCanvas(dynamic inputImg, { int width: -1, int height: -1 }) async {
 final PictureRecorder pictureRecorder = PictureRecorder();
 final Canvas canvas = Canvas(pictureRecorder);
  if (width == -1 && height == -1) {
   width = inputImg.width;
   height = inputImg.height;
 } else if (width == -1) {
   double ratio = inputImg.width / inputImg.height;
   width = (height * ratio).toInt();
 } else {
   double ratio = inputImg.height / inputImg.width;
   height = (width * ratio).toInt();
 }

 final Rect inRect = Offset.zero & Size(inputImg.width.toDouble(), inputImg.height.toDouble());
 final Rect outRect = Offset.zero & Size(width.toDouble(), height.toDouble());
  canvas.drawImageRect(inputImg, inRect, outRect, Paint());
  final img = await pictureRecorder.endRecording().toImage(width, height);
 final data = await img.toByteData(format: ImageByteFormat.png);
 return data.buffer.asUint8List();
}

 

Maintenant que nous avons nos données chargées et transmises à notre widget "GoMap", nous allons pouvoir commencer à détecter et capturer des langages !

 

Détection et affichage des entités

 

Nous allons rajouter quelques méthodes à notre widget "GoMap" afin de pouvoir chasser les langages correctement.


Premièrement, il nous faut une méthode qui va nous permettre d’utiliser l’action register (que nous avons implémenté plus haut dans notre backend) en envoyant la position passée en argument :

 

_registerPositionToServer(Position position) async {
   await kuzzle.query(KuzzleRequest(
     controller: 'kuzzle-plugin-advanced-boilerplate/location',
     action: 'register',
     body: {
       'latitude': position.latitude,
       'longitude': position.longitude
     }
   ));
 }

 

Ensuite, il nous faut une méthode qui va chercher toutes les entités dans un périmètre de 50 mètres autour de notre position :

 

_searchEntities(Position position) async {
   return await kuzzle.document.search(
     'world',
     'collectable',
     size: 100,
     query: {
       'query': {
         'bool': {
           'must': { 'match_all' : {} },
           'filter': {
             'geo_distance': {
               'distance': '50m',
               'location': {
                 'lat': position.latitude,
                 'lon': position.longitude
               }
             }
           }
         }
       }
     }
   );
 }

 

Nous devons maintenant penser à la manière dont les collectables vont s’afficher sur notre carte : nous allons utiliser les marqueurs de Google Map. Il suffit simplement de rajouter une propriété "_markers" à notre widget :

 

 Set<Marker> _markers = {};

 

On passe ensuite cette propriété à notre map :

 

GoogleMap(   
           onMapCreated: _onMapCreated,
           initialCameraPosition: _initialPosition,
           markers: _markers,
           myLocationEnabled: true,
           mapType: MapType.terrain,
           rotateGesturesEnabled: false,
           scrollGesturesEnabled: false,
           tiltGesturesEnabled: false,
           zoomGesturesEnabled: false
         ),

 

Une fois que les marqueurs sont en place, nous pouvons facilement créer une méthode qui va nous permettre de définir les marqueurs affichés sur la map. On crée une nouvelle méthode qui prend en paramètre les résultats de la méthode" _searchEntities" créés précédemment et les convertit en marqueurs sur la carte :

 

_updateMarkers(results) {
   setState(() {
     _markers.clear();

     for (var collectable in results.hits) {
       final markerId = MarkerId('${collectable['_source']['location']['lat']}#${collectable['_source']['location']['lon']}');

       _markers.add(Marker(
         markerId: markerId,
         position: LatLng(collectable['_source']['location']['lat'], collectable['_source']['location']['lon']),
         icon: entities[collectable['_source']['entity']]['asset'],
         consumeTapEvents: true
       ));
     }
   });
 }

 

Maintenant que nous avons toutes les méthodes requises, notre fonction "_synchronize" précédemment créée peut évoluer vers ça:

 

_synchronize() async {
   Position position = await Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.high);

   this._registerPositionToServer(position);

   _controller.animateCamera(
     CameraUpdate.newLatLng(LatLng(position.latitude, position.longitude)),
   );

   final results = await this._searchEntities(position);
   this._updateMarkers(results);

   sleep(new Duration(seconds: 1));
   this._synchronize();
 }

 

Avec tout ce nouveau code, nous pouvons maintenant assister à l’apparition de langages sur notre carte :

 

apparition-langages-code-carte

 

Let’s catch them all!

 

Capture des entités

 

Il ne reste qu’un peu de code à ajouter pour rendre la capture fonctionnelle. Nous allons faire une méthode qui prend en paramètre un ID de marqueur ainsi qu’un collectable et qui supprime le marqueur, supprime le collectable de la map, affiche une alert et enregistre dans les "shared preferences" un compteur du nombres de cette entité que nous avons collectée :

 

 _catchEntity(markerId, collectable) {
   return () async {
     final entity = entities[collectable['_source']['entity']];

     showDialog(
       context: context,
       builder: (BuildContext context) {
         return CaughtAlert(entity: entity, count: prefs.getInt(entity['name']) ?? 0);
       },
     );

     setState(() => _markers.removeWhere((marker) => marker.markerId == markerId));
     kuzzle.document.delete('world', 'collectable', collectable['_id']);

     int counter = (prefs.getInt(entity['name']) ?? 0) + 1;
     await prefs.setInt(entity['name'], counter);
   };
 }

 

Il faut aussi créer le widget de l’alert, une simple fenêtre qui affiche le nom, l’image et la description d’une entité capturée :

 

class CaughtAlert extends StatelessWidget {
 CaughtAlert({ @required this.entity, @required this.count });
 final dynamic entity;
 final int count;

 @override
 Widget build(BuildContext context) {
   return AlertDialog(
     title: Text('You caught ${entity['name']}!'),
     content: Column(
       children: <Widget>[
         Image.network(
           entity['image'],
           width: 100
         ),
         SizedBox(height: 20),
         Text(entity['description']),
       ],
       mainAxisSize: MainAxisSize.min
     ),
     actions: <Widget>[
       Text('Stock: $count'),
       FlatButton(
         child: Text("OK"),
         onPressed: () {
           Navigator.of(context).pop();
         },
       ),
     ],
   );
 }
}

 

Une fois que tout ça est fait, la dernière étape est de modifier notre méthode "_updateMarkers" pour ajouter un event handler sur le clic des marqueurs pour pouvoir appeler notre méthode "_catchEntity" créée précédemment: 

 

_updateMarkers(results) {
   setState(() {
     _markers.clear();

     for (var collectable in results.hits) {
       final markerId = MarkerId('${collectable['_source']['location']['lat']}#${collectable['_source']['location']['lon']}');

       _markers.add(Marker(
         markerId: markerId,
         position: LatLng(collectable['_source']['location']['lat'], collectable['_source']['location']['lon']),
         icon: entities[collectable['_source']['entity']]['asset'],
         consumeTapEvents: true,
         onTap: _catchEntity(markerId, collectable)
       ));
     }
   });
 }

 

On peut maintenant cliquer sur un langage sur la map pour voir la magie opérer

 

Javascript-attrapé-application-go

 

Avant que vous ne courriez dehors afin de collecter tous les langages de programmation du monde, pourquoi ne pas rajouter une page afin de visualiser toutes les entités que nous avons collectées ?

 

Listing des entités collectées

 

Nous allons simplement ajouter une deuxième page à notre "DefaultTabController" qui ne servira qu’à lister les entrées enregistrées dans les "shared_preferences". Nous commençons par créer notre nouveau widget. C’est une simple list qui prend en props les entités présentes dans le jeu ainsi que les "shared_preferences" du joueur. Le widget va ensuite aller chercher dans les préférences le nombre de captures pour chaque entité possible du jeu :

 

class CaughtList extends StatelessWidget {
 CaughtList({Key key, @required this.entities, @required this.prefs });
 final dynamic entities;
 final dynamic prefs;

 @override
 Widget build(BuildContext context) {
   final keys = entities.keys.toList();

   return ListView.builder(
     itemCount: keys.length,
     itemBuilder: (BuildContext ctx, int index) {
       final entity = entities[keys[index]];
       return Opacity(
         opacity: (prefs.getInt(entity['name']) ?? 0) > 0 ? 1.0 : 0.2,
         child: Row(
           children: <Widget>[
             Row(
               children: <Widget>[
                 Padding(
                   padding: EdgeInsets.all(16.0),
                   child: Image.network(
                     entity['image'],
                     width: 50
                   )
                 ),
                 SizedBox(height: 20),
                 Text(entity['name'], style: TextStyle(
                   fontWeight: FontWeight.bold,
                   fontSize: 16.0,
                 )),
               ]
             ),
             Padding(
               padding: EdgeInsets.all(16.0),
               child: Text('${prefs.getInt(entity['name']) ?? 0}', style: TextStyle(
                 fontSize: 18.0,
               )),
             )
           ],
           mainAxisAlignment: MainAxisAlignment.spaceBetween
         )
       );
     }
   );
 }
}

 

Il ne reste qu’à ajouter ce widget dans notre widget "App" afin de créer la nouvelle page :

 

DefaultTabController(
       length: 2,
       child: Scaffold(
         appBar: AppBar(
           bottom: TabBar(
             tabs: [
               Tab(text: 'Map', icon: Icon(Icons.map)),
               Tab(text: 'Stock', icon: Icon(Icons.library_books)),
             ],
           ),
         ),
         body: TabBarView(
           children: [
             GoMap(entities: _entities, prefs: _prefs),
             CaughtList(entities: _entities, prefs: _prefs),
           ],
         ),
       ),
     ),

 

On peut maintenant voir apparaître dans notre application cette superbe page qui sera un peu notre langage-dex (moins sexy que le pokédex…).

 

langage-dex-poké-dex

 

Et voilà ! Vous pouvez également essayer d’ajouter certaines fonctionnalités à l’appli : 

 

  • donner une probabilité d’apparition à chaque entité
  • un moyen de capturer les entités sans simplement cliquer dessus (pourquoi ne pas écrire un hello world dans le langage que l’on veut capturer ?)
  • des objets bonus (pour faire apparaître plus de langages par exemple)

 

Le code complet de l’application est disponible ici.

 

Bonne chasse ;)

 

Thomas Arbona

Postes associés