World Wide Maze est un jeu dans lequel vous devez guider une bille à travers des labyrinthes 3D créés à partir de sites Web pour essayer d'atteindre des points d'arrivée.
Le jeu utilise de nombreuses fonctionnalités HTML5. Par exemple, l'événement DeviceOrientation récupère les données d'inclinaison du smartphone, qui sont ensuite envoyées au PC via WebSocket, où les joueurs se frayent un chemin dans les espaces 3D créés par WebGL et Web Workers.
Dans cet article, je vais vous expliquer précisément comment ces fonctionnalités sont utilisées, le processus de développement global et les points clés à prendre en compte pour l'optimisation.
DeviceOrientation
L'événement DeviceOrientation (exemple) permet de récupérer les données d'inclinaison du smartphone. Lorsque addEventListener
est utilisé avec l'événement DeviceOrientation
, un rappel avec l'objet DeviceOrientationEvent
est appelé en tant qu'argument à intervalles réguliers. Les intervalles eux-mêmes varient en fonction de l'appareil utilisé. Par exemple, sous iOS + Chrome et iOS + Safari, le rappel est appelé environ toutes les 1/20e de seconde, tandis que sous Android 4 + Chrome, il est appelé environ toutes les 1/10e de seconde.
window.addEventListener('deviceorientation', function (e) {
// do something here..
});
L'objet DeviceOrientationEvent
contient des données d'inclinaison pour chacun des axes X
, Y
et Z
en degrés (et non en radians) (en savoir plus sur HTML5Rocks). Toutefois, les valeurs renvoyées varient également en fonction de la combinaison d'appareil et de navigateur utilisés. Les plages des valeurs renvoyées réelles sont présentées dans le tableau ci-dessous:
Les valeurs en haut, surlignées en bleu, sont celles définies dans les spécifications du W3C. Les éléments en vert correspondent à ces spécifications, tandis que ceux en rouge s'en écartent. Étonnamment, seule la combinaison Android-Firefox a renvoyé des valeurs correspondant aux spécifications. Toutefois, en termes d'implémentation, il est plus judicieux de prendre en compte les valeurs qui reviennent fréquemment. World Wide Maze utilise donc les valeurs de retour iOS par défaut et les ajuste en conséquence pour les appareils Android.
if android and event.gamma > 180 then event.gamma -= 360
Le Nexus 10 n'est toutefois pas compatible. Bien que le Nexus 10 renvoie la même plage de valeurs que les autres appareils Android, un bug inverse les valeurs bêta et gamma. Nous y travaillons séparément. (Peut-être que l'orientation par défaut est le mode Paysage ?)
Comme le montre cet exemple, même si les API impliquant des appareils physiques ont des spécifications définies, il n'est pas garanti que les valeurs renvoyées correspondent à ces spécifications. Il est donc essentiel de les tester sur tous les appareils potentiels. Cela signifie également que des valeurs inattendues peuvent être saisies, ce qui nécessite de trouver des solutions de contournement. World Wide Maze invite les joueurs novices à calibrer leur appareil à l'étape 1 de son tutoriel, mais il ne se calibre pas correctement sur la position 0 s'il reçoit des valeurs d'inclinaison inattendues. Il dispose donc d'une limite de temps interne et invite le joueur à passer aux commandes au clavier s'il ne parvient pas à le calibrer dans ce délai.
WebSocket
Dans World Wide Maze, votre smartphone et votre PC sont connectés via WebSocket. Plus précisément, ils sont connectés via un serveur de relais, c'est-à-dire du smartphone au serveur, puis au PC. En effet, WebSocket ne permet pas de connecter directement les navigateurs entre eux. (L'utilisation des canaux de données WebRTC permet une connectivité point à point et élimine le besoin d'un serveur de relais, mais au moment de l'implémentation, cette méthode ne pouvait être utilisée qu'avec Chrome Canary et Firefox Nightly.)
J'ai choisi d'implémenter à l'aide d'une bibliothèque appelée Socket.IO (v0.9.11), qui inclut des fonctionnalités de reconnexion en cas de délai avant expiration de la connexion ou de déconnexion. Je l'ai utilisé avec NodeJS, car cette combinaison NodeJS + Socket.IO a affiché les meilleures performances côté serveur lors de plusieurs tests d'implémentation WebSocket.
Associer des nombres
- Votre PC se connecte au serveur.
- Le serveur attribue à votre PC un nombre généré de manière aléatoire et mémorise la combinaison du nombre et du PC.
- Sur votre appareil mobile, spécifiez un numéro et connectez-vous au serveur.
- Si le numéro indiqué est le même que celui d'un PC connecté, votre appareil mobile est associé à ce PC.
- Si aucun PC n'est désigné, une erreur se produit.
- Lorsque des données arrivent de votre appareil mobile, elles sont envoyées au PC avec lequel il est associé, et vice-versa.
Vous pouvez également établir la connexion initiale depuis votre appareil mobile. Les appareils sont simplement inversés dans ce cas.
Synchronisation des onglets
La fonctionnalité de synchronisation des onglets spécifique à Chrome facilite encore plus le processus d'association. Grâce à elle, vous pouvez facilement ouvrir les pages ouvertes sur votre ordinateur sur un appareil mobile (et inversement). Le PC prend le numéro de connexion émis par le serveur et l'ajoute à l'URL d'une page à l'aide de history.replaceState
.
history.replaceState(null, null, '/maze/' + connectionNumber)
Si la synchronisation des onglets est activée, l'URL est synchronisée au bout de quelques secondes et la même page peut être ouverte sur l'appareil mobile. L'appareil mobile vérifie l'URL de la page ouverte. Si un numéro y est ajouté, la connexion commence immédiatement. Vous n'avez donc plus besoin de saisir manuellement des chiffres ni de scanner des codes QR avec un appareil photo.
Latence
Étant donné que le serveur de relais se trouve aux États-Unis, l'accès à partir du Japon entraîne un délai d'environ 200 ms avant que les données d'inclinaison du smartphone n'atteignent le PC. Les temps de réponse étaient clairement lents par rapport à ceux de l'environnement local utilisé pendant le développement, mais l'insertion d'un filtre passe-bas (j'ai utilisé EMA) a amélioré ce paramètre à des niveaux non gênants. (En pratique, un filtre passe-bas était également nécessaire à des fins de présentation. Les valeurs renvoyées par le capteur d'inclinaison incluaient une quantité considérable de bruit, et l'application de ces valeurs à l'écran tel quel entraînait de nombreux tremblements.) Cela ne fonctionnait pas avec les sauts, qui étaient clairement lents, mais rien ne pouvait être fait pour résoudre ce problème.
Comme je m'attendais à des problèmes de latence dès le départ, j'ai envisagé de configurer des serveurs de relais dans le monde entier afin que les clients puissent se connecter au plus proche disponible (ce qui réduit la latence). Cependant, j'ai fini par utiliser Google Compute Engine (GCE), qui n'existait qu'aux États-Unis à l'époque, ce qui n'était pas possible.
Problème de l'algorithme Nagle
L'algorithme Nagle est généralement intégré aux systèmes d'exploitation pour une communication efficace en tamponnant au niveau TCP. Toutefois, j'ai constaté que je ne pouvais pas envoyer de données en temps réel lorsque cet algorithme était activé. (en particulier en cas de combinaison avec l'acquittement différé TCP). Même si ACK
n'est pas retardé, le même problème se produit si ACK
est retardé dans une certaine mesure en raison de facteurs tels que l'emplacement du serveur à l'étranger.)
Le problème de latence Nagle ne s'est pas produit avec WebSocket dans Chrome pour Android, qui inclut l'option TCP_NODELAY
permettant de désactiver Nagle, mais il s'est produit avec le WebSocket WebKit utilisé dans Chrome pour iOS, pour lequel cette option n'est pas activée. (Safari, qui utilise le même WebKit, a également rencontré ce problème. Le problème a été signalé à Apple via Google et a apparemment été résolu dans la version de développement de WebKit.
Lorsque ce problème se produit, les données d'inclinaison envoyées toutes les 100 ms sont combinées en blocs qui n'atteignent le PC que toutes les 500 ms. Le jeu ne peut pas fonctionner dans ces conditions. Il évite donc cette latence en demandant au côté serveur d'envoyer des données à de courts intervalles (environ toutes les 50 ms). Je pense que la réception de ACK
à de courts intervalles trompe l'algorithme Nagle en lui faisant croire qu'il est acceptable d'envoyer des données.
Le graphique ci-dessus représente les intervalles de données réelles reçues. Il indique les intervalles de temps entre les paquets. Le vert représente les intervalles de sortie et le rouge les intervalles d'entrée. La valeur minimale est de 54 ms, la valeur maximale est de 158 ms et la valeur médiane est proche de 100 ms. Ici, j'ai utilisé un iPhone avec un serveur de relais situé au Japon. La sortie et l'entrée sont d'environ 100 ms, et le fonctionnement est fluide.
À l'inverse, ce graphique montre les résultats obtenus en utilisant le serveur aux États-Unis. Alors que les intervalles de sortie verts restent stables à 100 ms, les intervalles d'entrée fluctuent entre 0 ms et 500 ms, ce qui indique que le PC reçoit des données par blocs.
Enfin, ce graphique montre les résultats de l'évitement de la latence en demandant au serveur d'envoyer des données d'espace réservé. Bien que les performances ne soient pas tout à fait aussi bonnes que celles du serveur japonais, il est clair que les intervalles d'entrée restent relativement stables à environ 100 ms.
Un bug ?
Bien que le navigateur par défaut d'Android 4 (ICS) dispose d'une API WebSocket, il ne peut pas se connecter, ce qui entraîne un événement connect_failed Socket.IO. En interne, le délai avant expiration est dépassé, et le côté serveur ne peut pas non plus vérifier la connexion. (Je n'ai pas testé cela avec WebSocket seul. Il peut donc s'agir d'un problème Socket.IO.)
Évoluer les serveurs relais
Étant donné que le rôle du serveur de relais n'est pas si compliqué, l'ajustement à la hausse et l'augmentation du nombre de serveurs ne devraient pas être difficiles, à condition de vous assurer que le même PC et l'appareil mobile sont toujours connectés au même serveur.
Physique
Le mouvement de la balle dans le jeu (rouler en pente, entrer en collision avec le sol, entrer en collision avec les murs, collecter des objets, etc.) est entièrement géré par un simulateur de physique 3D. J'ai utilisé Ammo.js, un port du moteur physique Bullet largement utilisé en JavaScript à l'aide d'Emscripten, ainsi que Physijs pour l'utiliser en tant que "Web Worker".
Web Worker
Les Web Workers sont une API permettant d'exécuter du code JavaScript dans des threads distincts. Le code JavaScript lancé en tant que Web Worker s'exécute en tant que thread distinct de celui qui l'a appelé à l'origine. Vous pouvez ainsi effectuer des tâches lourdes tout en maintenant la réactivité de la page. Physijs utilise efficacement les Web Workers pour aider le moteur physique 3D normalement intensif à fonctionner correctement. World Wide Maze gère le moteur physique et le rendu d'images WebGL à des fréquences d'images complètement différentes. Par conséquent, même si la fréquence d'images diminue sur une machine aux spécifications limitées en raison d'une charge de rendu WebGL importante, le moteur physique lui-même maintient plus ou moins 60 FPS et n'entrave pas les commandes du jeu.
Cette image montre les fréquences d'images obtenues sur un Lenovo G570. La zone supérieure indique la fréquence d'images pour WebGL (rendu d'image), et la zone inférieure indique la fréquence d'images pour le moteur physique. Le GPU est une puce Intel HD Graphics 3000 intégrée. Par conséquent, la fréquence d'images de rendu d'image n'a pas atteint les 60 FPS attendus. Toutefois, comme le moteur physique a atteint la fréquence d'images attendue, le gameplay n'est pas si différent des performances sur une machine haut de gamme.
Étant donné que les threads avec des Web Workers actifs ne disposent pas d'objets de console, les données doivent être envoyées au thread principal via postMessage pour générer des journaux de débogage. L'utilisation de console4Worker crée l'équivalent d'un objet de console dans le worker, ce qui facilite considérablement le processus de débogage.
Les versions récentes de Chrome vous permettent de définir des points d'arrêt lors du lancement de Web Workers, ce qui est également utile pour le débogage. Vous pouvez y accéder dans le panneau "Workers" (Travailleurs) des outils pour les développeurs.
Performances
Les étapes comportant un nombre élevé de polygones dépassent parfois 100 000 polygones,mais les performances n'en ont pas particulièrement souffert, même lorsqu'elles ont été générées entièrement en tant que Physijs.ConcaveMesh
(btBvhTriangleMeshShape
dans Bullet).
Au départ, la fréquence d'images diminuait à mesure que le nombre d'objets nécessitant une détection de collision augmentait, mais l'élimination du traitement inutile dans Physijs a amélioré les performances. Cette amélioration a été apportée à une fourche de Physijs d'origine.
Objets fantômes
Les objets qui disposent d'une détection de collision, mais qui n'ont aucun impact en cas de collision et donc aucun effet sur les autres objets sont appelés "objets fantômes" dans Bullet. Bien que Physijs ne prenne pas officiellement en charge les objets fantômes, il est possible de les créer en modifiant les indicateurs après avoir généré un Physijs.Mesh
. World Wide Maze utilise des objets fantômes pour la détection des collisions entre les éléments et les points d'objectif.
hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)
Pour collision_flags
, 1 correspond à CF_STATIC_OBJECT
et 4 à CF_NO_CONTACT_RESPONSE
. Pour en savoir plus, essayez de rechercher sur le forum Bullet, sur Stack Overflow ou dans la documentation Bullet. Étant donné que Physijs est un wrapper pour Ammo.js et qu'Ammo.js est fondamentalement identique à Bullet, la plupart des choses que vous pouvez faire dans Bullet peuvent également être effectuées dans Physijs.
Problème avec Firefox 18
La mise à jour de Firefox de la version 17 à la version 18 a modifié la façon dont les Web Workers échangent des données. Par conséquent, Physijs a cessé de fonctionner. Le problème a été signalé sur GitHub et résolu au bout de quelques jours. Cette efficacité Open Source m'a impressionné, mais cet incident m'a également rappelé que World Wide Maze est composé de plusieurs frameworks Open Source différents. J'écris cet article dans l'espoir de vous fournir des commentaires.
asm.js
Bien que cela ne concerne pas directement World Wide Maze, Ammo.js est déjà compatible avec l'asm.js récemment annoncé par Mozilla (ce qui n'est pas surprenant, car asm.js a été créé principalement pour accélérer le code JavaScript généré par Emscripten, et le créateur d'Emscripten est également le créateur d'Ammo.js). Si Chrome prend également en charge asm.js, la charge de calcul du moteur physique devrait diminuer considérablement. La vitesse était nettement plus rapide lors des tests avec Firefox Nightly. Il serait peut-être préférable d'écrire les sections qui nécessitent plus de vitesse en C/C++, puis de les porter vers JavaScript à l'aide d'Emscripten ?
WebGL
Pour l'implémentation de WebGL, j'ai utilisé la bibliothèque la plus développée, three.js (r53). Bien que la révision 57 ait déjà été publiée lors des dernières étapes de développement, des modifications majeures ont été apportées à l'API. J'ai donc conservé la révision d'origine pour la publication.
Effet de lueur
L'effet de lueur ajouté au cœur de la balle et aux éléments est implémenté à l'aide d'une version simple de la méthode dite "MGF Kawase". Cependant, alors que la méthode Kawase fait fleurir toutes les zones lumineuses, World Wide Maze crée des cibles de rendu distinctes pour les zones qui doivent briller. En effet, une capture d'écran d'un site Web doit être utilisée pour les textures de scène. Si vous extrayez simplement toutes les zones lumineuses, l'ensemble du site Web s'illuminera, par exemple s'il a un arrière-plan blanc. J'ai également envisagé de tout traiter en HDR, mais je me suis abstenu cette fois, car l'implémentation aurait été très compliquée.
En haut à gauche, vous pouvez voir la première passe, où les zones de lueur ont été rendues séparément, puis un flou a été appliqué. En bas à droite, vous pouvez voir la deuxième étape, où la taille de l'image a été réduite de 50 %, puis un floutage a été appliqué. En haut à droite, vous pouvez voir la troisième étape, où l'image a été à nouveau réduite de 50 %, puis floutée. Les trois images ont ensuite été superposées pour créer l'image composite finale en bas à gauche. Pour le flou, j'ai utilisé VerticalBlurShader
et HorizontalBlurShader
, inclus dans three.js. Il reste donc encore de la place pour l'optimisation.
Boule réfléchissante
La réflexion sur la balle est basée sur un exemple de three.js. Toutes les directions sont affichées à partir de la position de la balle et sont utilisées comme cartes d'environnement. Les cartes d'environnement doivent être mises à jour chaque fois que la balle bouge, mais comme la mise à jour à 60 FPS est intensive, elles sont mises à jour toutes les trois images. Le résultat n'est pas aussi fluide que la mise à jour de chaque frame, mais la différence est pratiquement imperceptible, sauf si elle est signalée.
Nuanceur, nuanceur, nuanceur…
WebGL nécessite des nuanceurs (vertex shaders, fragment shaders) pour tous les rendus. Bien que les nuanceurs inclus dans three.js permettent déjà d'obtenir un large éventail d'effets, vous devez écrire vos propres nuanceurs pour obtenir un ombrage et une optimisation plus élaborés. Comme World Wide Maze occupe le processeur avec son moteur physique, j'ai essayé d'utiliser le GPU à la place en écrivant autant que possible en langage d'ombrage (GLSL), même lorsque le traitement par le processeur (via JavaScript) aurait été plus simple. Les effets de vagues de l'océan reposent naturellement sur des nuanceurs, tout comme les feux d'artifice aux points d'objectif et l'effet de maillage utilisé lorsque la balle apparaît.
L'image ci-dessus provient de tests de l'effet de maillage utilisé lorsque la balle apparaît. Celui de gauche est celui utilisé dans le jeu, composé de 320 polygones. Celle du centre utilise environ 5 000 polygones, et celle de droite environ 300 000 polygones. Même avec autant de polygones, le traitement avec des nuanceurs peut maintenir une fréquence d'images stable de 30 FPS.
Les petits éléments éparpillés sur la scène sont tous intégrés dans un seul maillage, et le mouvement individuel repose sur des nuanceurs qui déplacent chacun des sommets des polygones. Il s'agit d'un test visant à déterminer si les performances sont affectées par la présence d'un grand nombre d'objets. Environ 5 000 objets sont représentés ici, composés d'environ 20 000 polygones. Les performances n'ont pas été affectées.
poly2tri
Les étapes sont créées en fonction des informations de contour reçues du serveur, puis polygonisées par JavaScript. La triangulation, qui est un élément clé de ce processus, est mal implémentée par three.js et échoue généralement. J'ai donc décidé d'intégrer moi-même une autre bibliothèque de triangulation appelée poly2tri. Il s'avère que three.js avait déjà essayé de faire la même chose par le passé. J'ai donc réussi à le faire fonctionner en commentant une partie de celui-ci. Le nombre d'erreurs a ainsi diminué de manière significative, ce qui a permis de proposer un plus grand nombre de niveaux jouables. L'erreur occasionnelle persiste et, pour une raison quelconque, poly2tri gère les erreurs en émettant des alertes. Je l'ai donc modifié pour qu'il génère des exceptions à la place.
L'image ci-dessus montre comment le contour bleu est triangulé et comment des polygones rouges sont générés.
Filtrage anisotrope
Étant donné que le mappage MIP isotrope standard réduit les images sur les axes horizontal et vertical, l'affichage des polygones sous des angles obliques donne l'impression que les textures à l'extrémité des niveaux de World Wide Maze ressemblent à des textures de faible résolution allongées horizontalement. L'image en haut à droite de cette page Wikipédia en est un bon exemple. En pratique, une résolution horizontale plus élevée est requise, ce que WebGL (OpenGL) résout à l'aide d'une méthode appelée filtrage anisotrope. Dans three.js, définir une valeur supérieure à 1 pour THREE.Texture.anisotropy
active le filtrage anisotrope. Toutefois, cette fonctionnalité est une extension et peut ne pas être compatible avec tous les GPU.
Optimiser
Comme indiqué dans cet article sur les bonnes pratiques WebGL, le moyen le plus efficace d'améliorer les performances de WebGL (OpenGL) consiste à réduire les appels de dessin. Lors du développement initial de World Wide Maze, toutes les îles, les ponts et les barrières du jeu étaient des objets distincts. Cela entraînait parfois plus de 2 000 appels de dessin, ce qui rendait les étapes complexes difficiles à gérer. Cependant, une fois que j'ai empaqueté les mêmes types d'objets dans un seul maillage, le nombre d'appels de dessin est passé à environ 50, ce qui a considérablement amélioré les performances.
J'ai utilisé la fonctionnalité de traçage Chrome pour une optimisation supplémentaire. Les outils de profilage inclus dans les outils pour les développeurs de Chrome peuvent déterminer dans une certaine mesure les temps de traitement globaux des méthodes, mais le traçage peut vous indiquer précisément la durée de chaque partie, à la milliseconde près. Pour savoir comment utiliser le traçage, consultez cet article.
Les résultats ci-dessus sont les résultats de la création de cartes d'environnement pour la réflexion de la balle. L'insertion de console.time
et de console.timeEnd
dans des emplacements apparemment pertinents dans three.js nous donne un graphique qui se présente comme suit. Le temps s'écoule de gauche à droite, et chaque couche est un peu comme une pile d'appels. L'imbrication d'une console.time dans une console.time
permet d'effectuer d'autres mesures. Le graphique du haut correspond à la période avant l'optimisation, et celui du bas à la période après l'optimisation. Comme le montre le graphique du haut, updateMatrix
(bien que le mot soit tronqué) a été appelé pour chacun des rendus 0 à 5 lors de la pré-optimisation. Je l'ai modifié pour qu'il ne soit appelé qu'une seule fois, car ce processus n'est nécessaire que lorsque les objets changent de position ou d'orientation.
Le processus de traçage lui-même consomme des ressources, naturellement. Par conséquent, insérer console.time
de manière excessive peut entraîner une déviation importante des performances réelles, ce qui rend difficile l'identification des axes d'optimisation.
Ajusteur de performances
En raison de la nature d'Internet, le jeu sera probablement joué sur des systèmes dont les spécifications varient considérablement. Find Your Way to Oz, sorti début février, utilise une classe appelée IFLAutomaticPerformanceAdjust
pour réduire les effets en fonction des fluctuations du débit d'images, ce qui permet de garantir un visionnage fluide. World Wide Maze s'appuie sur la même classe IFLAutomaticPerformanceAdjust
et réduit les effets dans l'ordre suivant pour rendre le jeu aussi fluide que possible:
- Si la fréquence d'images passe en dessous de 45 FPS, les cartes d'environnement ne sont plus mises à jour.
- Si la fréquence d'images est toujours inférieure à 40 FPS, la résolution de rendu est réduite à 70% (50% du ratio de surface).
- Si le nombre de FPS passe toujours en dessous de 40 FPS, l'anticrénelage FXAA est supprimé.
- Si le nombre de FPS est toujours inférieur à 30, les effets de lueur sont supprimés.
Fuite de mémoire
La suppression d'objets de manière soignée est un peu compliquée avec Three.js. Mais les laisser sans surveillance entraînerait évidemment des fuites de mémoire. J'ai donc conçu la méthode ci-dessous. @renderer
fait référence à THREE.WebGLRenderer
. (La dernière version de three.js utilise une méthode de désallocation légèrement différente. Il est donc probable que cette méthode ne fonctionne pas avec elle telle quelle.)
destructObjects: (object) =>
switch true
when object instanceof THREE.Object3D
@destructObjects(child) for child in object.children
object.parent?.remove(object)
object.deallocate()
object.geometry?.deallocate()
@renderer.deallocateObject(object)
object.destruct?(this)
when object instanceof THREE.Material
object.deallocate()
@renderer.deallocateMaterial(object)
when object instanceof THREE.Texture
object.deallocate()
@renderer.deallocateTexture(object)
when object instanceof THREE.EffectComposer
@destructObjects(object.copyPass.material)
object.passes.forEach (pass) =>
@destructObjects(pass.material) if pass.material
@renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
@renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
@renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2
HTML
Personnellement, je pense que le meilleur atout de l'application WebGL est la possibilité de concevoir la mise en page des pages en HTML. Créer des interfaces 2D telles que des affichages de score ou de texte dans Flash ou openFrameworks (OpenGL) est assez pénible. Flash dispose au moins d'un IDE, mais openFrameworks est difficile si vous n'y êtes pas habitué (utiliser quelque chose comme Cocos2D peut vous faciliter la tâche). Le langage HTML, en revanche, permet de contrôler précisément tous les aspects de la conception du frontend avec le CSS, comme lors de la création de sites Web. Bien que les effets complexes tels que la condensation de particules en logo soient impossibles, certains effets 3D sont possibles avec les transformations CSS. Les effets de texte "GOAL" et "TIME IS UP" de World Wide Maze sont animés à l'aide de l'échelle dans la transition CSS (implémentée avec Transit). (Évidemment, les gradations d'arrière-plan utilisent WebGL.)
Chaque page du jeu (titre, RÉSULTAT, CLASSEMENT, etc.) dispose de son propre fichier HTML. Une fois ces fichiers chargés en tant que modèles, $(document.body).append()
est appelé avec les valeurs appropriées au moment opportun. Il y avait un problème : les événements de souris et de clavier ne pouvaient pas être définis avant l'ajout. Par conséquent, l'ajout de el.click (e) -> console.log(e)
avant l'ajout ne fonctionnait pas.
Internationalisation (i18n)
Le code HTML s'est également avéré pratique pour créer la version en anglais. J'ai choisi d'utiliser i18next, une bibliothèque Web d'internationalisation, pour mes besoins d'internationalisation. Je l'ai utilisée telle quelle, sans modification.
La modification et la traduction du texte du jeu ont été effectuées dans une feuille de calcul Google Docs. Comme i18next nécessite des fichiers JSON, j'ai exporté les feuilles de calcul au format TSV, puis les ai converties à l'aide d'un convertisseur personnalisé. J'ai apporté de nombreuses modifications juste avant la publication. L'automatisation du processus d'exportation à partir de la feuille de calcul Google Docs aurait donc été beaucoup plus simple.
La fonctionnalité de traduction automatique de Chrome fonctionne également normalement, car les pages sont créées avec du code HTML. Toutefois, il arrive qu'elle ne détecte pas correctement la langue, et la confonde avec une autre (par exemple, vietnamien), cette fonctionnalité est actuellement désactivée. (Vous pouvez la désactiver à l'aide de balises Meta.)
RequireJS
J'ai choisi RequireJS comme système de modules JavaScript. Les 10 000 lignes de code source du jeu sont réparties en environ 60 classes (= fichiers coffee) et compilées dans des fichiers js individuels. RequireJS charge ces fichiers individuels dans l'ordre approprié en fonction des dépendances.
define ->
class Hoge
hogeMethod: ->
La classe définie ci-dessus (hoge.coffee) peut être utilisée comme suit:
define ['hoge'], (Hoge) ->
class Moge
constructor: ->
@hoge = new Hoge()
@hoge.hogeMethod()
Pour fonctionner, hoge.js doit être chargé avant moge.js. Étant donné que "hoge" est désigné comme premier argument de "define", hoge.js est toujours chargé en premier (appelé une fois le chargement de hoge.js terminé). Ce mécanisme est appelé AMD. Toute bibliothèque tierce peut être utilisée pour le même type de rappel, à condition qu'elle prenne en charge AMD. Même ceux qui ne le font pas (par exemple, three.js) fonctionneront de la même manière tant que les dépendances sont spécifiées à l'avance.
Cela ressemble à l'importation d'AS3. Il ne devrait donc pas vous sembler si étrange. Si vous obtenez plus de fichiers dépendants, cette solution est possible.
r.js
RequireJS inclut un optimiseur appelé r.js. Cela regroupe le fichier JavaScript principal avec tous les fichiers JavaScript dépendants en un seul, puis le minimise à l'aide d'UglifyJS (ou Closure Compiler). Cela réduit le nombre de fichiers et la quantité totale de données que le navigateur doit charger. La taille totale du fichier JavaScript de World Wide Maze est d'environ 2 Mo et peut être réduite à environ 1 Mo avec l'optimisation r.js. Si le jeu pouvait être distribué à l'aide de gzip, cette valeur serait encore réduite à 250 Ko. (GAE présente un problème qui ne permet pas la transmission de fichiers gzip de 1 Mo ou plus. Le jeu est donc actuellement distribué non compressé sous la forme de 1 Mo de texte brut.)
Outil de création de scènes
Les données d'étape sont générées comme suit, entièrement sur le serveur GCE situé aux États-Unis:
- L'URL du site Web à convertir en étape est envoyée via WebSocket.
- PhantomJS prend une capture d'écran, et les positions des balises div et img sont récupérées et affichées au format JSON.
- À partir de la capture d'écran de l'étape 2 et des données de positionnement des éléments HTML, un programme C++ personnalisé (OpenCV, Boost) supprime les zones inutiles, génère des îlots, les relie à l'aide de ponts, calcule les positions des rails de protection et des éléments, définit le point d'arrivée, etc. Les résultats sont affichés au format JSON et renvoyés au navigateur.
PhantomJS
PhantomJS est un navigateur qui ne nécessite aucun écran. Il peut charger des pages Web sans ouvrir de fenêtres. Il peut donc être utilisé dans des tests automatisés ou pour capturer des captures d'écran côté serveur. Son moteur de navigateur est WebKit, le même que celui utilisé par Chrome et Safari. Par conséquent, sa mise en page et les résultats de l'exécution JavaScript sont également plus ou moins les mêmes que ceux des navigateurs standards.
Avec PhantomJS, JavaScript ou CoffeeScript sont utilisés pour écrire les processus que vous souhaitez exécuter. Il est très facile de prendre des captures d'écran, comme illustré dans cet exemple. Je travaillais sur un serveur Linux (CentOS). J'ai donc dû installer des polices pour afficher le japonais (M+ FONTS). Même dans ce cas, l'affichage des polices est géré différemment que sous Windows ou macOS. Par conséquent, la même police peut avoir un aspect différent sur d'autres machines (la différence est toutefois minime).
La récupération des positions des balises img et div est essentiellement gérée de la même manière que sur les pages standards. jQuery peut également être utilisé sans problème.
stage_builder
J'ai d'abord envisagé d'utiliser une approche plus basée sur le DOM pour générer des étapes (similaire à l'inspecteur 3D de Firefox) et j'ai essayé quelque chose comme une analyse DOM dans PhantomJS. Finalement, j'ai opté pour une approche de traitement d'image. Pour ce faire, j'ai écrit un programme C++ qui utilise OpenCV et Boost, appelé "stage_builder". Il effectue les opérations suivantes:
- Charge la capture d'écran et les fichiers JSON.
- Convertit les images et le texte en "îles".
- Crée des ponts pour relier les îles.
- Élimine les ponts inutiles pour créer un labyrinthe.
- Place les éléments volumineux.
- Place les petits articles.
- Place des garde-fous.
- Exporte les données de positionnement au format JSON.
Chaque étape est détaillée ci-dessous.
Charger la capture d'écran et les fichiers JSON
Le cv::imread
habituel est utilisé pour charger des captures d'écran. J'ai testé plusieurs bibliothèques pour les fichiers JSON, mais picojson m'a semblé la plus facile à utiliser.
Convertir des images et du texte en "îles"
La capture d'écran ci-dessus montre la section "Actualités" de aid-dcc.com (cliquez pour afficher la taille réelle). Les éléments d'image et de texte doivent être convertis en îles. Pour isoler ces sections, nous devons supprimer la couleur d'arrière-plan blanche, c'est-à-dire la couleur la plus répandue dans la capture d'écran. Voici à quoi cela ressemble une fois que vous avez terminé:
Les sections blanches correspondent aux îles potentielles.
Le texte est trop fin et net. Nous allons donc l'épaissir avec cv::dilate
, cv::GaussianBlur
et cv::threshold
. Le contenu de l'image est également manquant. Nous allons donc remplir ces zones en blanc, en fonction des données de sortie de la balise img de PhantomJS. L'image obtenue se présente comme suit:
Le texte forme désormais des groupes appropriés, et chaque image est une île appropriée.
Créer des ponts pour relier les îles
Une fois les îles prêtes, elles sont reliées par des ponts. Chaque île recherche des îles adjacentes à gauche, à droite, au-dessus et en dessous, puis connecte un pont au point le plus proche de l'île la plus proche, ce qui donne quelque chose comme ceci:
Supprimer les ponts inutiles pour créer un labyrinthe
Si vous conservez tous les ponts, l'étape sera trop facile à parcourir. Vous devez donc en supprimer certains pour créer un labyrinthe. Une île (par exemple, celle en haut à gauche) est choisie comme point de départ, et tous les ponts (sauf un, sélectionné de manière aléatoire) qui y sont connectés sont supprimés. Ensuite, procédez de la même manière pour l'île suivante reliée par le pont restant. Une fois que le chemin arrive à une impasse ou vous ramène à une île déjà visitée, il revient en arrière jusqu'à un point qui permet d'accéder à une nouvelle île. Le labyrinthe est terminé une fois que toutes les îles ont été traitées de cette manière.
Placer des éléments volumineux
Un ou plusieurs grands éléments sont placés sur chaque île en fonction de ses dimensions, en choisissant les points les plus éloignés des bords de l'île. Bien que pas très clairs, ces points sont indiqués en rouge ci-dessous:
Parmi tous ces points possibles, celui en haut à gauche est défini comme point de départ (cercle rouge), celui en bas à droite comme objectif (cercle vert) et un maximum de six autres sont choisis pour l'emplacement des grands éléments (cercle violet).
Placer de petits articles
Un nombre approprié de petits éléments est placé le long de lignes à des distances définies par rapport aux bords de l'île. L'image ci-dessus (qui ne provient pas d'aid-dcc.com) montre les lignes d'emplacement projetées en gris, décalées et placées à intervalles réguliers à partir des bords de l'île. Les points rouges indiquent l'emplacement des petits éléments. Comme cette image provient d'une version en cours de développement, les éléments sont disposés en lignes droites, mais dans la version finale, ils sont répartis de manière un peu plus irrégulière de chaque côté des lignes grises.
Placer des garde-fous
Les garde-corps sont généralement placés le long des limites extérieures des îlots, mais doivent être coupés au niveau des ponts pour permettre l'accès. La bibliothèque de géométrie Boost s'est avérée utile pour cela, en simplifiant les calculs géométriques, par exemple pour déterminer où les données de limite d'une île se croisent avec les lignes de chaque côté d'un pont.
Les lignes vertes qui délimitent les îlots sont les garde-corps. Il peut être difficile de le voir sur cette image, mais il n'y a pas de lignes vertes là où se trouvent les ponts. Il s'agit de l'image finale utilisée pour le débogage, où tous les objets qui doivent être générés au format JSON sont inclus. Les points bleu clair représentent les petits éléments, et les points gris les points de redémarrage proposés. Lorsque la balle tombe dans l'océan, le jeu reprend à partir du point de reprise le plus proche. Les points de reprise sont disposés à peu près de la même manière que les petits éléments, à intervalles réguliers et à une distance définie du bord de l'île.
Émission de données de positionnement au format JSON
J'ai également utilisé picojson pour la sortie. Il écrit les données sur la sortie standard, qui sont ensuite reçues par l'appelant (Node.js).
Créer un programme C++ sur un Mac à exécuter sous Linux
Le jeu a été développé sur un Mac et déployé sous Linux, mais comme OpenCV et Boost existaient pour les deux systèmes d'exploitation, le développement en lui-même n'a pas été difficile une fois l'environnement de compilation établi. J'ai utilisé les outils de ligne de commande dans Xcode pour déboguer la compilation sur Mac, puis j'ai créé un fichier de configuration à l'aide d'automake/autoconf afin que la compilation puisse être effectuée sous Linux. J'ai ensuite simplement utilisé "configure && make" sous Linux pour créer le fichier exécutable. J'ai rencontré des bugs spécifiques à Linux en raison de différences de versions de compilateur, mais j'ai pu les résoudre relativement facilement à l'aide de gdb.
Conclusion
Un jeu de ce type peut être créé avec Flash ou Unity, ce qui présente de nombreux avantages. Cependant, cette version ne nécessite aucun plug-in, et les fonctionnalités de mise en page de HTML5 + CSS3 se sont révélées extrêmement puissantes. Il est important de disposer des outils appropriés pour chaque tâche. J'ai été personnellement surpris par la qualité du jeu, qui a été entièrement créé en HTML5. Bien qu'il manque encore de nombreux éléments, j'ai hâte de voir comment il évoluera à l'avenir.