Un nouveau jeu avec une vieille ambiance sur Three.js. 2e partie

Dans la première partie, j'ai parlé des problèmes que j'ai rencontrés lors de la création d'un jeu 3D pour le navigateur utilisant Three.js. Maintenant, je voudrais m'attarder en détail sur la résolution de certains problèmes importants lors de l'écriture d'un jeu, tels que la construction de niveaux, la détection de collisions et l'adaptation de l'image à n'importe quel rapport d'aspect de la fenêtre du navigateur.


Diagrammes de niveau


En fait, les niveaux eux-mêmes sont créés dans l'éditeur 3D, à savoir leur géométrie, la cartographie des textures, la cuisson des ombres, etc. J'ai décrit tout cela dans la première partie. Pourquoi existe-t-il d'autres régimes? Le fait est que Three.js n'offre aucun type de moteur physique, et j'utilise des schémas de niveaux pour identifier les obstacles.


Three.js pour résoudre le problème de collision n'offre que le lancer de rayons - le moyen le plus simple de déterminer l'intersection de la géométrie des objets. En principe, il peut être utilisé, et je l'ai même fait dans l'un de mes autres projets. C'était une ville virtuelle directement sur le site, dans le navigateur. Vous pouvez vous déplacer dans la ville et ne pas passer à travers les murs.


Dans le cas où l'intersection de la géométrie du joueur et du bâtiment se produit pendant le mouvement, j'ai mis en œuvre la répulsion du joueur d'une certaine distance dans la direction opposée au mur. Mais pour cela, les objets doivent être parallélépipèdes. Autour de quelques objets complexes, j'ai créé des collisionneurs (nous appellerons les objets invisibles qui jouent le rôle d'obstacles et empêchent le joueur de se traverser), le long desquels des intersections ont été aménagées. Et les parties inférieures de certains bâtiments, qui sont simplement des «boîtes», étaient parfois utilisées comme collisionneurs elles-mêmes.


Sur les objets géométriquement complexes, le lancer de rayons peut ne pas fonctionner ou se comporter de manière inappropriée. Et, juste comme solution, vous pouvez intégrer dans l'objet non pas un, mais plusieurs petits collisionneurs invisibles sous forme de parallélépipèdes avec une transparence à 100%, dessinés les uns à côté des autres et les uns sur les autres, répétant grossièrement la forme de l'objet.

Dans le jeu sur les donjons, le niveau est un seul objet long avec des mouvements coupés pour déplacer le joueur. En fait, pour résoudre le problème de collision, on pourrait coller des collisionneurs invisibles si nécessaire et utiliser le raytracing. Cependant, j'ai décidé d'aller dans l'autre sens.


  • Tout d'abord, je voulais automatiser le processus de création d'un tableau de collisionneurs.
  • Deuxièmement, vous ne pouvez utiliser que des informations sur les collisionneurs, c'est-à-dire leurs coordonnées dans l'espace, et ne pas charger la scène 3D elle-même avec des objets vides supplémentaires.
  • Troisièmement, puisque le jeu n'utilise qu'une vue latérale et que l'une des coordonnées ne change jamais lors du déplacement, vous pouvez utiliser le calcul des intersections en seulement deux coordonnées.
  • Et quatrièmement, après tout, en fait, il y aura un schéma de niveaux. De plus, trouver de nouveaux niveaux est tout simplement pratique à partir d'un tel schéma. Vous pouvez simplement faire glisser des blocs autour de l'écran dans n'importe quel éditeur graphique, construire de nouveaux couloirs et obstacles, puis exécuter le script et obtenir des informations sur les collisionneurs. Autrement dit, le problème de l'éditeur de niveau est partiellement résolu.

J'ai écrit un script qui prend des paramètres d'entrée tels que le nom du fichier de schéma de niveaux (png) et la couleur, dont le remplissage est interprété comme un obstacle. La couleur d'espace libre par défaut est le noir. Pour le traitement par le script, le schéma de chaque niveau doit être enregistré dans un fichier png séparé. Par exemple, pour le niveau le plus bas, cela ressemble à ceci:


J'ai convenu qu'un bloc devrait avoir une largeur de 80 pixels et une hauteur de 48 pixels. Cela correspond à 4 x 2,4 mètres dans le monde 3D. Il serait possible de faire 40 x 24 pixels, c'est-à-dire dix fois, mais dans l'image, cela semble petit.

Le résultat du script au premier niveau (l'image est rognée à droite):


Le script est exécuté dans le navigateur. Je pense que le balisage html ne sert à rien, c'est élémentaire: des champs de saisie de données et un bouton de démarrage. Ensuite, l'image lue est affichée sur la toile. Et à la suite du script, un tableau est affiché sous l'image à l'échelle mondiale 3D, qui contient les coordonnées inférieure gauche et supérieure droite de chaque bloc, et avec le décalage spécifié dans le script pour chaque niveau. Ce tableau peut être copié et collé dans la liste des collisionneurs à utiliser dans le jeu (plus d'informations ci-dessous), il sera stocké dans une sorte de constante. Les coordonnées apparaissent également sur l'image elle-même, mais dans le cadre de référence de l'image 2D. Ces nombres sont affichés au centre de chaque bloc et vous permettent de vérifier si tous les blocs sont inclus dans le calcul. À eux seuls, ces numéros ne sont nécessaires que pour une inspection visuelle. Certains blocs, comme les colonnes entre lesquelles passe le joueur, ne doivent pas être comptés. A propos des objets exclus du calcul - ci-dessous.

De plus, par exemple, au deuxième niveau, il y a de fines plaques horizontales sur lesquelles le joueur marche. Ils doivent être pris en considération. Par conséquent, vous devez vous assurer que les numéros y figurent également. Dans le diagramme, faites-les 2 pixels de haut.



Maintenant, sur la façon dont le script prend en compte les blocs:

  • Le schéma est traité par blocs de 80x48, dans chacun desquels une zone est prise du 2e au 79e pixels horizontalement et du 2e au 47e pixels verticalement. Le premier et le dernier pixel ne sont pas utilisés afin que autour des blocs vous puissiez créer un cadre noir d'une largeur de 1 pixel, cela améliore la perception visuelle du circuit et facilite sa création.
  • Tous les pixels de la ligne supérieure du bloc sont affichés. S'il y en a des colorés, les coordonnées du bloc vont du premier au dernier pixel coloré horizontalement et à la pleine hauteur du bloc verticalement dans le tableau final. Ce sera un bloc vierge à pleine ou partielle largeur.
  • Tous les pixels de la dernière ligne du bloc sont affichés. S'il y a des couleurs parmi eux, mais qu'il n'y en a pas de couleur dans la rangée supérieure, les coordonnées du bloc vont du premier au dernier pixel coloré horizontalement et 3 pixels verticalement du bas au tableau final. Ce sera une plateforme sur laquelle marcher. À l'intérieur d'un bloc, il peut y avoir plusieurs plates-formes horizontales. Les plateformes ne sont reconnues qu'en bas du bloc. Les coordonnées de la plate-forme sont «enfoncées» dans un bloc, qui est situé en dessous, de sorte que la surface de la plate-forme soit au même niveau que les blocs voisins - pas les plates-formes.
  • Les colonnes et autres décorations à l'intérieur d'un bloc vide ne sont pas traitées, car seules les lignes supérieure et inférieure de pixels sont prises en compte. Par conséquent, à l'intérieur du bloc, vous pouvez placer un décor, des explications pour le diagramme, des pointeurs, des colonnes, etc., sans craindre que cela n'affecte en quelque sorte le résultat du script.

Ensuite, toutes les coordonnées reçues du réseau sont traduites à l'échelle du monde 3D, multipliée par le coefficient de son échelle (qui a été sélectionné dans l'éditeur 3D lors de sa création). La matrice est prête à être utilisée dans le jeu. Le code du script a été écrit à la hâte, il ne prétend donc pas être élégant, mais il remplit sa tâche.

Code
ap = { //      (  ),   3D   lvd: { 'lv01.png': { invw: false, invh: true, level_dw: -8.5, level_dh: -1.5 }, 'lv02.png': { invw: true, invh: true, level_dw: -19.5, level_dh: -5.5 } }, blockw: 80, //   2D blockh: 48, //   2D sc3d: 0.05, //,   3D  ex: 100, //  3D (-   ) v: { data: [] }, i: 0, par: {}, datai: [], resi: [], ars: [], fStopEncode: false, blockColor: function(cl) { document.getElementById('input_cl').value = cl; }, startEncode: function() { //      for (var key in ap.lvd) { ap.lvd[key].dw = ap.lvd[key].level_dw * ap.blockw; ap.lvd[key].dh = ap.lvd[key].level_dh * ap.blockh; }; document.getElementById('startbtn').style.display = 'none'; document.getElementById('startmsg').style.display = 'block'; var cl = document.getElementById('input_cl').value; var fld = document.getElementById('input_fld').value; var nm = document.getElementById('input_nm').value; ap.nm = nm; ap.par = { path: [fld + '/', nm], key: [nm], cl: aplib.hexToRgb(cl.substring(1, 7)) }; setTimeout(function() { ap.datai[ap.par.key] = new Image(); ap.datai[ap.par.key].onload = function() { ap.parseData(); }; ap.datai[ap.par.key].src = ap.par.path[0] + ap.par.path[1]; }, 500); }, stopEnode: function(e) { if (typeof ap !== "undefined") { if (e.keyCode == 27) { console.log('stop'); ap.fStopEncode = true; }; }; }, parseData: function() { ap.w = ap.datai[ap.par.key[0]].width, ap.h = ap.datai[ap.par.key[0]].height; aplib.initCanv(ap.w, ap.h); ctx.drawImage(ap.datai[ap.par.key[0]], 0, 0, ap.w, ap.h, 0, 0, ap.w, ap.h); ap.ars = []; ap.i = 0; setTimeout(function() { ap.parseData1(); }, 1000); }, parseData1: function() { if (ap.i < ap.par.key.length) { document.getElementById('info').innerHTML = '' + ap.nm; ap.blocksw = Math.floor(ap.w / ap.blockw); ap.blocksh = Math.floor(ap.h / ap.blockh); ap.ar = []; ap.arv = {}; ap.hi = 0; ctx.fillStyle = '#CCCCCC'; ap.parseData2(); } else { document.getElementById('startbtn').style.display = 'block'; document.getElementById('startmsg').style.display = 'none'; }; }, parseData2: function() { if (ap.hi < ap.blocksh) { ap.ar.push([]); ap.wi = 0; ap.parseData3(); } else { ap.parseData4(); }; }, parseData3: function() { var k = ''; if (ap.wi < ap.blocksw) { var fground = true, fvari = false, fempty = true; var upx1 = 0, upx2 = 0, dnx1 = 0, dnx2 = 0; var upxf = false, dnxf = false; for (var wii = 1; wii < ap.blockw - 2 + 2; wii++) { pixelDatai = ctx.getImageData(ap.wi * ap.blockw + wii, ap.hi * ap.blockh + 1, 1, 1).data; //  pixelDatai2 = ctx.getImageData(ap.wi * ap.blockw + wii, (ap.hi + 1) * ap.blockh - 3, 1, 1).data; //  if ((pixelDatai[0] == ap.par.cl.r) & (pixelDatai[1] == ap.par.cl.g) & (pixelDatai[2] == ap.par.cl.b)) { //   ground    if (upxf == false) { upxf = true; upx1 = wii; }; } else { //    if (upxf == true) { upx2 = wii + 1; upx1--; //   dy = -1; // 3D       1 ap.v.data.push([ap.wi * ap.blockw + upx1, ap.hi * ap.blockh + dy, ap.wi * ap.blockw + upx2, ap.hi * (ap.blockh) + ap.blockh - 1]); upxf = false; upx1 = 0; upx2 = 0; }; }; if ((pixelDatai2[0] == ap.par.cl.r) & (pixelDatai2[1] == ap.par.cl.g) & (pixelDatai2[2] == ap.par.cl.b)) { //   ground     if (upxf == false) { if (dnxf == false) { dnxf = true dnx1 = wii; }; }; } else { if (upxf == false) { if (dnxf == true) { dnx2 = wii + 1; dnx1--; //   dy = 2; // 3D    2 ap.v.data.push([ap.wi * ap.blockw + dnx1, (ap.hi + 1) * ap.blockh - 3 + dy, ap.wi * ap.blockw + dnx2, (ap.hi + 1) * ap.blockh - 3 + 2 + dy]); dnxf = false; dnx1 = 0; dnx2 = 0; }; }; }; }; if (ap.fStopEncode == true) { ap.hi = ap.h, ap.wi = ap.w, i = ap.par.key.length; }; setTimeout(function() { ap.wi++; ap.parseData3(); }, 10); } else { ap.hi++; ap.parseData2(); }; }, parseData4: function() { setTimeout(function() { var t, tw, tx, ty, ar = []; //  for (var i = 0; i < ap.v.data.length; i++) { ar = ap.v.data[i]; t = ar[0] + ';' + (ar[1]+1) + '<br/>' + ar[2] + ';' + (ar[3]+1); tw = ar[2] - ar[0]; tx = ar[0]; ty = ar[1] + Math.floor((ar[3] - ar[1]) / 2) - 0; aplib.Tex2Canvas(ctx, t, 'normal 10px Arial', 10, '#CCCCCC', tx, ty, tw, 0, 'center', 'top'); }; ap.parseData5(); }, 10); }, parseData5: function() { var t, tw, tx, ty, ar = [], n; //   3D var lv = ap.lvd[ap.nm]; for (var i = 0; i < ap.v.data.length; i++) { ar = ap.v.data[i]; ar[0] += lv.dw; ar[1] += lv.dh; ar[2] += lv.dw; ar[3] += lv.dh; if (lv.invh == true) { n = -ar[1]; ar[1] = -ar[3]; ar[3] = n; }; if (lv.invw == true) { n = -ar[0] ar[0] = -ar[2]; ar[2] = n; }; ar[0] = Math.round(ap.sc3d * ar[0] * ap.ex) / ap.ex; ar[1] = Math.round(ap.sc3d * ar[1] * ap.ex) / ap.ex; ar[2] = Math.round(ap.sc3d * ar[2] * ap.ex) / ap.ex; ar[3] = Math.round(ap.sc3d * ar[3] * ap.ex) / ap.ex; }; //    ap.v.data.sort(aplib.sortBy0); console.log(ap.v.data); document.getElementById('divresult').innerHTML = JSON.stringify(ap.v.data); } }; aplib = { hexToRgb: function(hex) { var arrBuff = new ArrayBuffer(4); var vw = new DataView(arrBuff); vw.setUint32(0, parseInt(hex, 16), false); var arrByte = new Uint8Array(arrBuff); return { r: arrByte[1], g: arrByte[2], b: arrByte[3], s: arrByte[1] + "," + arrByte[2] + "," + arrByte[3] }; }, //   canvas Tex2Canvas: function(ctx, t, font, lin, fcolor, x, y, w, h, haln, valn) { //left, right, center, center-lim- ctx.font = font; ctx.fillStyle = fcolor; var l = 0; var tx = x; var ftw = false; var tw = 1; var arr = t.split('<br/>'); for (var i = 0; i < arr.length; i++) { arr[i] = arr[i].split(' '); }; for (var i = 0; i < arr.length; i++) { var s = '', slen = 0, s1 = '', j = 0; while (j < arr[i].length) { var wordcount = 0; while ((slen < w) & (j < arr[i].length)) { s = s1; s1 = s + arr[i][j] + ' '; slen = ctx.measureText(s1).width; if (slen < w) { j++; wordcount++; } else { if (wordcount > 0) { s1 = s; } else { j++; }; }; }; ftw = false; tw = ctx.measureText(s1).width; if (haln == 'center') { tx = x + Math.round((w - tw) / 2); }; if (haln == 'right') { tx = x + Math.round((w - tw)); }; if (haln == 'center-lim') { if (tw > w) { tw = w; }; if (tw < 1) { tw = 1; }; tx = x + Math.round((w - tw) / 2); ftw = true; }; if (ftw == false) { ctx.fillText(s1, tx, l * lin + y); } else { ctx.fillText(s1, tx, l * lin + y, tw); }; if (s1 == '') { j = arr[i].length + 1; }; l++; s1 = ''; slen = 0; }; }; return Math.round(tw); }, // canvas initCanv: function(w, h) { function canvErr() { document.getElementById('divcanv').innerHTML = '<div style="height:130px"></div><div style="width:440px; border:#FFFFFF 1px solid; margin:10px; padding:4px; background-color:#000000"><p class="txterr">---> Error<br/>HTML5 Canvas is not supported!<br/>Please, update your browser!</p></div>'; }; if (w == 0) { w = 740; h = 680; }; elcanv = document.getElementById('divcanv'); elcanv.innerHTML = '<canvas id="canv" style="width:' + w + 'px; height:' + h + 'px; display:block;" width="' + w + '" height="' + h + '"></canvas>'; canvas1 = document.getElementById('canv'); if (!canvas1) { canvErr(); return 0; } else { if (canvas1.getContext) { ctx = canvas1.getContext('2d'); ctx.clearRect(0, 0, w, h); return 1; } else { canvErr(); }; }; }, sortBy0: function(i, ii) { if (i[0] > ii[0]) return 1; else if (i[0] < ii[0]) return -1; else return 0; } }; 


Maintenant - sur la façon dont le jeu fonctionne avec un tableau de blocs. Le jeu utilise des couloirs (niveaux) qui se croisent. Lorsqu'un joueur se transforme en couloir, un nouveau tableau de blocs est connecté: et pour chaque couloir, en conséquence, son propre tableau est obtenu, obtenu à partir de son schéma de niveaux. Pendant le mouvement du joueur, ses coordonnées sont vérifiées pour être à l'intérieur de chaque bloc. Et s'il est à l'intérieur d'un bloc, nous obtenons une collision. Mais à chaque mouvement du joueur, nous n'avons pas besoin de chercher des intersections avec tous les blocs du niveau, car il peut y en avoir beaucoup. Créez un tableau composé uniquement des blocs les plus proches du joueur.

 collisionsUpdate: function(x, y, dw, dh) { var coll = []; var o; for (var i = 0; i < ap.v.lv.d.length; i++) { o = ap.v.lv.d[i]; if ((o[0] >= x - ap.v.dw) & (o[2] <= x + ap.v.dw)) { if ((o[1] >= y - ap.v.dh) & (o[3] <= y + ap.v.dh)) { coll.push(o); }; }; }; ap.v.coll = coll; }, 

Ici, à l'entrée x, y sont les coordonnées actuelles du joueur, dw, dh est la distance à laquelle vous souhaitez rechercher des blocs horizontalement et verticalement, par exemple 12 et 8 mètres. En d'autres termes, prenez tous les blocs autour du joueur dans un carré de 24x16 mètres. Ils participeront à la recherche d'affrontements. ap.v.lv.d [i] est un élément d'un tableau de blocs du niveau actuel, en fait, il est lui-même aussi un tableau de 4 nombres définissant les limites d'un bloc - [x1, y1, x2, y2], donc, pour vérifier le carré horizontalement, nous prenons des éléments d'indices 0 et 2, et verticalement - 1 et 3. S'il y a une correspondance, ajoutez ce bloc à la liste des collisions ap.v.coll.

Lorsque le joueur bouge, nous mettrons à jour cette liste de collisions, mais, pour économiser les performances, nous ne le ferons pas à chaque étape (ou plutôt, en rendant le cadre), mais lorsque le joueur quitte un certain carré, légèrement plus petit, spécifié dans ap.v.collwStep et ap.v.collhStep, par exemple 8 et 4 mètres. Autrement dit, nous allons réassembler le tableau de collision lorsque le joueur passe un certain chemin horizontalement ou verticalement à partir de sa position d'origine. En même temps, rappelons-nous sa position à laquelle nous avons remonté le tableau afin de l'utiliser pour la prochaine itération. pers [ax] - ici, par hache, nous entendons l'axe des coordonnées (ax), il peut être x ou z, selon la direction du couloir le long duquel le joueur marche.

 //   if ((Math.abs(pers[ax] - ap.v.collw) > ap.v.collwStep) || (Math.abs(pers.y - ap.v.collh) > ap.v.collhStep)) { ap.v.collw = pers[ax]; ap.v.collh = pers.y; ap.collisionsUpdate(pers[ax], pers.y, 12, 8); }; 

Pourquoi de telles difficultés? Pourquoi ne pas utiliser toute la gamme de collisions au niveau et ne pas prendre de bain de vapeur. Le fait est que la détection de collision est effectuée selon un algorithme beaucoup plus complexe, et il n'est pas rentable de vérifier la collision avec absolument tous les blocs de niveau, et non les plus proches, à chaque rendu de la trame. (Bien que ce ne soit pas exact.)

La définition des collisions à chaque rendu d'une trame à l'aide du tableau de collisions préparé ci-dessus:

Code
 collisionsDetect: function(x, y, xOld, yOld, up) { //up=-1 -  var res = false, o; var collw = false, collh = false, collwi = false, collhi = false, collhsup = false, support = [], supportf = false, fw = false, upb = -1; var bub = -1, bubw = 0; var pw2 = ap.v.player.pw2, ph2 = ap.v.player.ph2, supportd = ap.v.supportd; for (var i = 0; i < ap.v.coll.length; i++) { o = ap.v.coll[i]; collwi = false; collhi = false; collhsup = false; fw = false; if ((x + pw2 >= o[0]) & (x - pw2 <= o[2])) { if ((y + ph2 > o[1]) & (y - ph2 < o[3])) { collwi = true; }; }; //     if ((xOld + pw2 >= o[0]) & (xOld - pw2 <= o[2])) { if ((yOld + ph2 > o[1]) & (yOld - ph2 < o[3])) { bub = i; if (Math.abs(xOld - o[0]) < Math.abs(xOld - o[2])) { bubw = -1; } else { bubw = 1; }; }; }; if ((x >= o[0]) & (x <= o[2])) { fw = true; //  i   }; if ((y + ph2 >= o[1]) & (y - ph2 <= o[3])) { if ((x > o[0]) & (x < o[2])) { collhi = true; //  if (y + ph2 > o[3]) { collhsup = true; supportf = true; support = o; upb = 1; }; //  if (y - ph2 < o[1]) { upb = -1; }; }; }; if ((y - ph2 >= o[3] + supportd - 0.11) & (y - ph2 <= o[3] + supportd + 0.001)) { if (fw == true) { collhi = true; collh = true; res = true; collhsup = true; supportf = true; support = o; }; }; if (collwi & collhi) { res = true; }; if (collwi) { collw = true; }; if (collhi) { collh = true; }; }; return { f: res, w: collw, h: collh, support: support, supportf: supportf, upb: upb, bub: bub, bubw: bubw }; }, 


Ici x, y, xOld, yOld sont les coordonnées nouvelles et actuelles du joueur. Les nouveaux sont calculés sur simple pression d'un bouton, en fonction d'une vitesse de déplacement donnée, c'est-à-dire qu'il s'agit de coordonnées possibles. Ils sont vérifiés pour voir s'ils entrent dans un bloc de la liste des collisions. S'ils tombent, ils retournent aux anciens et le joueur ne passe pas à travers l'obstacle. Et s'ils ne tombent pas, ils deviennent courants. pw2 et ph2 sont la moitié de la largeur et de la hauteur du collisionneur imaginaire du joueur (largeur du joueur / 2, hauteur du joueur / 2). La sortie est émise s'il y a une collision horizontale et verticale (collw, collh), s'il y a un bloc de support sous le lecteur (supportf) - cela indique clairement s'il faut continuer l'animation de chute ou si le joueur est simplement passé à un bloc voisin, et ainsi de suite. Ne me demandez simplement pas pourquoi j'y ai ajouté 0,001 et soustrait 0,11. C'est une terrible béquille qui empêche de tomber à travers les blocs et l'effet de gigue lors d'une collision avec un obstacle horizontal ... Cette fonction fonctionne, mais elle doit être réécrite de manière normale. L'optimisation de cette fonction fait également défaut.

Je pense qu'avec les collisions, ça vaut la peine de s'arrêter là.

Il est difficile de dire à quel point ma méthode est plus rapide ou peut-être plus lente que le lancer de rayons, mais dans ce dernier cas, Three.js stocke également un tableau d'objets qui participent au système de collision. C'est juste que les collisions là-bas sont déterminées par la méthode d'émission du faisceau et son intersection avec les plans des côtés des objets, et avec moi, en déterminant si les coordonnées d'un objet sont à l'intérieur de l'autre le long de chacun des deux axes.

Le jeu a également des objets en mouvement (requin) et des objets marqueurs qui déclenchent une sorte d'animation (par exemple, le contact avec l'eau déclenche le mouvement d'un requin). Tous ces objets participent également aux collisions, et certains avec des coordonnées variant dans le temps. Là, curieusement, tout est plus simple: lors du déplacement de l'objet ses coordonnées sont comparées aux coordonnées du joueur.


Gamepad


En général, la maintenance d'une manette de jeu javascript dans un navigateur n'est pas une tâche triviale. Il n'y a aucun événement de pression et de relâchement de bouton. Il n'y a que des événements qui connectent et déconnectent le périphérique et l'état qui peut être obtenu par interrogation périodique, puis comparez-le avec le précédent.

Une vidéo montrant le fonctionnement de la manette de jeu dans un navigateur sur une tablette sous Windows 8.1 et un PC sous Windows 10. Cependant, la tablette est une ancienne, sortie en 2014, donc l'éclairage dynamique est désactivé dans le jeu.


Pour interroger la manette de jeu, une fonction appelée une fois toutes les 100 millisecondes est utilisée. Il est défini à l'aide de la fonction de ma bibliothèque m3d.lib.globalTimer.addEvent.

 m3d.lib.globalTimer.addEvent({ name: 'gamepad', ti: 100, f: function() { var st = m3d.gamepad.state(); if (st == false) { if (contr.gpDownFlag == true) { m3d.gamepad.resetH(); }; }; } }); 

GlobalTimer est ici le système de gestion d'événements javascript setInterval timer que j'ai écrit. Là, simplement une série d'événements sont ajoutés à un certain tableau qui doivent être appelés à différents intervalles. Ensuite, un temporisateur setInterval est défini avec la fréquence correspondant à l'événement avec la fréquence la plus élevée de toutes. Le temporisateur interroge la fonction m3d.lib.globalTimer.update (), qui parcourt la liste de tous les événements et exécute les fonctions de ceux qui sont venus pour s'exécuter. Lors de l'ajout ou de la suppression d'événements, la fréquence d'intervalle peut également changer (par exemple, si vous supprimez l'événement le plus rapide).

Le jeu définit également des gestionnaires pour chaque touche de la manette de jeu: 'a' est pour l'axe (hache), 'b' pour le bouton (bouton), et 11 est l'écart gauche le long de l'axe horizontal de la croix (comme si son bouton 1), 12 - la déviation droite le long de l'axe horizontal de la croix (comme si son bouton 2), 21 et 22 - pour l'axe vertical. Par exemple:

[«a», 11],
['b', 3]

signifie que la fonction suivante sera réglée en même temps pour la déviation le long de l'axe horizontal vers la gauche et pour le bouton 3 (gauche). Eh bien, une fonction est définie qui sera exécutée lorsque le bouton sera enfoncé, puis relâché.

  m3d.gamepad.setHandler( [ ['a', 11], ['b', 3] ], function(v) { if (contr.btState.lt == false) { contr.keyDownFlag = true; contr.btState.lt = true; contr.gpDownFlag = true; apcontrolsRenderStart(); }; }, function(v) { contr.btState.lt = false; m3d.contr.controlsCheckBt(); apcontrolsRenderStart(); } ); 

Ici, apcontrolsRenderStart () est une fonction qui lance un rendu s'il n'est pas déjà en cours d'exécution. En général, le support de la manette de jeu est étroitement lié à ma bibliothèque m3d, donc si je continue à décrire toutes ses fonctionnalités, il s'étirera très longtemps ...

Je ne vous en donnerai qu'une partie: la manette de jeu, dans laquelle j'ai implémenté l'initialisation de la manette de jeu, l'installation de gestionnaires et l'interrogation d'état de la manière la plus simple.

Code
 gamepad: { connected: false, gamepad: {}, gamepadKey: '', axesCount: 0, buttonsCount: 0, f: [], //  fup: [], //  fval: [], //      fupCall: [], //   buttons: [], //link to f [0.. ] axes: [], //link to f [0.. ] initCb: function() {}, resetH: function() {}, init: function(gp) { var f = false; for (var key in gp) { if (f == false) { if (gp[key] != null) { if (typeof gp[key].id !== "undefined") { f = true; this.connected = true; this.gamepad = gp[key]; this.gamepadKey = key; }; }; }; }; if (typeof this.gamepad.axes !== "undefined") { this.axesCount = this.gamepad.axes.length; }; if (typeof this.gamepad.buttons !== "undefined") { this.buttonsCount = this.gamepad.buttons.length; }; this.f = []; this.fup = []; this.fval = []; this.fupCall = []; this.axes = []; for (var i = 0; i < this.axesCount * 2; i++) { this.axes.push(-1); }; this.buttons = []; for (var i = 0; i < this.buttonsCount; i++) { this.buttons.push(-1); }; this.initCb(); }, setHandlerReset: function(f) { this.resetH = f; }, setHandler: function(ar, f, fup) { //ar['b',3] ['a',11] var fi, bt, ax, finext, finexta; finexta = false; for (var i = 0; i < ar.length; i++) { if (ar[i][0] == 'a') { ax = Math.floor(ar[i][1] / 10); bt = ar[i][1] - (ax * 10); bt = ax * 2 + bt - 3; fi = this.axes[bt]; if (fi == -1) { //   fi = this.f.length; if (finexta == false) { finexta = true; this.f.push(f); this.fup.push(fup); this.fval.push(0); this.fupCall.push(true); this.axes[bt] = fi; } else { fi--; this.f[fi] = f; this.fup[fi] = fup; this.axes[bt] = fi; }; } else { this.f[fi] = f; this.fup[fi] = fup; }; } else if (ar[i][0] == 'b') { bt = ar[i][1] - 1; fi = this.buttons[bt]; if (fi == -1) { //   fi = this.f.length; if (finexta == false) { finexta = true; this.f.push(f); this.fup.push(fup); this.fval.push(0); this.fupCall.push(true); this.buttons[bt] = fi; } else { fi--; this.f[fi] = f; this.fup[fi] = fup; this.buttons[bt] = fi; }; } else { this.f[fi] = f; this.fup[fi] = fup; }; }; }; }, state: function() { var pressed = false; var fi, fval, axesval; for (var i = 0; i < this.fval.length; i++) { this.fval[i] = 0; }; //   var gp = navigator.getGamepads()[this.gamepadKey]; for (var i = 0; i < this.axesCount; i++) { axesval = Math.round(gp.axes[i]); if (axesval < 0) { pressed = true; fi = this.axes[i * 2]; if (fi != -1) { this.fval[fi] = gp.axes[i]; this.fupCall[fi] = true; }; } else if (axesval > 0) { pressed = true; fi = this.axes[i * 2 + 1]; if (fi != -1) { this.fval[fi] = gp.axes[i]; this.fupCall[fi] = true; }; }; }; for (var i = 0; i < this.buttonsCount; i++) { if (gp.buttons[i].pressed == true) { pressed = true; fi = this.buttons[i]; if (fi != -1) { this.fval[fi] = 1; this.fupCall[fi] = true; }; }; }; for (var i = 0; i < this.fval.length; i++) { fval = this.fval[i]; if (fval != 0) { this.f[i](this.fval[i]); } else { if (this.fupCall[i] == true) { this.fupCall[i] = false; this.fup[i](this.fval[i]); }; }; }; return pressed; } }, //gamepad 


En général, le support du gamepad dans le jeu est encore incomplet: seul le support du gamepad le plus simple est implémenté, mais pas celui qui, par exemple, est utilisé dans la XBox, car je ne l'ai pas. Si je le reçois, je le programme et je travaille avec. Là, il sera possible d'ajuster la vitesse du personnage, c'est-à-dire qu'il sera possible de se déplacer à n'importe quelle vitesse dans la plage de l'étape à la course. Ceci est réalisé en prenant des paramètres fractionnaires des axes. Ma manette de jeu ne renvoie que les nombres entiers -1 et 1. De plus, ma manette de jeu a une croix dégoûtante, et lorsqu'elle est enfoncée à gauche ou à droite, elle appuie simultanément vers le bas ou vers le haut. Par conséquent, je n'ai pas utilisé le haut et le bas sur la croix et je l'ai dupliqué avec les boutons à droite de la manette de jeu ... À la sortie du jeu, je prévois de créer plusieurs profils de manettes de jeu. De plus, dans le cas de la connexion de plusieurs manettes de jeu, seule cette dernière sera utilisée jusqu'à présent.

Écran réactif


Le jeu est conçu pour un rapport d'aspect de 16: 9. Mais j'ai ajouté un ajustement horizontal automatique de ± 10% de sorte que dans la fenêtre du navigateur élargie, il n'y avait pas de telles barres noires sur les côtés:


Et ce serait comme ça:


En mode plein écran, il y aura un vrai 16: 9. Il serait possible d'adapter l'image en général à n'importe quel rapport d'aspect de la fenêtre du navigateur, mais je ne l'ai pas fait, car une fenêtre large et basse conduirait à un angle de vision trop grand, ce qui n'est pas bon du point de vue du gameplay: des impasses lointaines, des objets, des ennemis seront immédiatement visibles et tout ce que le joueur n'a pas encore besoin de voir. Par conséquent, je me suis limité à un ajustement à ± 10% de 16: 9. Cependant, pour les moniteurs étroits (4: 3), j'ai néanmoins réalisé la possibilité de passer de 16: 9 au mode d'adaptation de 4: 3 à 16: 9 en appuyant sur la touche Y. Mais pas plus large - donc, encore une fois, pour ne pas briser le gameplay. Autrement dit, vous pouvez jouer dans le rapport 16: 9 classique, ou vous pouvez agrandir l'image à la hauteur de la fenêtre en la recadrant horizontalement. Bien que cela ne soit pas très bon, par exemple, dans les situations d'arcade, lorsque quelque chose vole vers le joueur depuis le côté. Il reste peu de temps pour la réaction. Mais vous pouvez toujours revenir rapidement au mode classique.


L'adaptation de l'écran, ainsi que toutes les touches de raccourci utilisées dans le jeu sont présentées dans la vidéo suivante:


En fait, le rapport hauteur / largeur est défini dans les paramètres du jeu.

 aspect1:{w:1280, h:720, p:10}, //16x9 +- 10% aspect2:{w:960, h:720, p:34}, //4x3 +- 34% 

Et dans le jeu, lorsque vous appuyez sur Y, cela change:

 contr.btCodesDn[89] = function() { //'y' if (m3dcache.setup.aspect.swch == 1) { m3dcache.setup.aspect = m3dcache.setup.aspect2; m3dcache.setup.aspect.swch = 2; } else { m3dcache.setup.aspect = m3dcache.setup.aspect1; m3dcache.setup.aspect.swch = 1; }; m3d.core.onWindowResize(0); m3d.contr.renderAll(); }; 

Ma bibliothèque a un événement qui se bloque sur la fenêtre de redimensionnement. En voici un fragment:

Code
 m3dcache.v.vw = window.innerWidth; m3dcache.v.vh = window.innerHeight; m3dcache.v.vclipw = 0; m3dcache.v.vcliph = 0; if (typeof m3dcache.setup.aspect !== "undefined") { if ((m3dcache.setup.aspect.w == 0) || (m3dcache.setup.aspect.h == 0)) {} else { var o = m3d.lib.inBlock(0, 0, m3dcache.setup.aspect.w, m3dcache.setup.aspect.h, 0, 0, m3dcache.v.vw, m3dcache.v.vh, 'center', 'center', 'resize'); if (typeof m3dcache.setup.aspect.p !== "undefined") { if (o.clipx > 0) { ow = ow * (m3dcache.setup.aspect.p / 100 + 1); if (ow > m3dcache.v.vw) { ow = m3dcache.v.vw; }; o = m3d.lib.inBlock(0, 0, ow, oh, 0, 0, m3dcache.v.vw, m3dcache.v.vh, 'center', 'center', 'resize'); }; }; m3dcache.v.vclipw = o.clipx; m3dcache.v.vcliph = o.clipy; var margx = o.clipx + 'px', margy = o.clipy + 'px'; document.getElementById('m3dcontainer').style.marginLeft = margx; document.getElementById('m3dcontainer').style.marginTop = margy; if (document.getElementById('renderer') !== null) { document.getElementById('renderer').style.marginLeft = margx; document.getElementById('renderer').style.marginTop = margy; }; m3dcache.v.vw = ow; m3dcache.v.vh = oh; }; }; 


m3d.lib.inBlock est également une fonction de ma bibliothèque, qui inscrit un rectangle dans un autre rectangle avec des paramètres tels que le centrage, la mise à l'échelle ou le recadrage, et affiche les nouvelles dimensions du rectangle inscrit, ainsi que les tailles des champs qui sont formés dans ce processus. Sur la base de ces informations, le conteneur div de la fenêtre est positionné. 'renderer' est un élément de contexte de bloc d'une scène 3D. Ensuite, la toile y est mise à l'échelle en fonction des paramètres obtenus.

L'interface utilisateur est affichée dans le conteneur sur un élément de canevas distinct. En général, l'arborescence de documents se compose de trois blocs DIV transparents avec positionnement absolu (plus ou moins possible, selon les besoins du jeu): en bas se trouve le canevas de la scène 3D, au-dessus se trouve le canevas pour IU et le haut est utilisé pour animer des éléments d'interface et d'autres effets visuels . Autrement dit, l'interface utilisateur n'est pas rendue en 3D, mais sur son knavass, ou calque. La tâche de combiner les calques en une seule image est laissée au navigateur. Pour travailler avec l'interface utilisateur, j'ai un objet spécial dans la bibliothèque. En bref - l'essentiel est le suivant. Les listes de sprites avec des éléments d'interface utilisateur au format png avec transparence sont chargées. De là, les éléments nécessaires sont pris - arrière-plans, boutons. Et ils sont dessinés sur le canevas du milieu à l'aide de la fonction js drawImage (img, ix, iy, iw, ih, x, y, w, h). C'est-à-dire que les fragments nécessaires de l'image sont affichés dans les positions nécessaires sur l'écran. Les boutons sont affichés au-dessus des arrière-plans qui leur sont attachés - toutes leurs positions et tailles sont définies dans la configuration de l'interface utilisateur. Lors du redimensionnement d'une fenêtre, les positions des éléments sur le canevas cible (sur lesquelles ils sont affichés) sont recalculées, selon que tel ou tel élément est centré horizontalement et verticalement ou accroché à un coin ou une face de l'écran. Cela crée une interface utilisateur adaptative qui ne dépend pas du rapport d'aspect de l'écran. Seulement, il est nécessaire de définir la résolution minimale possible horizontalement et verticalement et de ne pas tomber en dessous afin que les éléments ne se chevauchent pas. Je parlerai de l'interface utilisateur une autre fois, car l'article s'est avéré volumineux, et je travaille toujours sur l'interface utilisateur, car il me manque encore beaucoup de fonctions. Par exemple, sur les moniteurs haute résolution, l'interface paraîtra petite. Vous pouvez multiplier la taille des éléments par un certain coefficient, selon la résolution de l'écran. D'un autre côté, peut-être que les énormes boutons sur l'écran ne sont pas nécessaires? Si la résolution d'écran est énorme, alors l'écran lui-même est assez grand.


Et vous pouvez donner au programmeur un choix - que ce soit pour redimensionner dynamiquement l'IU avec la taille de la fenêtre ou pour répartir les éléments dans les coins. Dans le cas de la taille dynamique, il y a aussi des questions qui leur sont propres - par exemple, le «savon» d'une interface lorsqu'elle est affichée à trop grande échelle. Si vous créez des sprites d'éléments d'interface dans une résolution délibérément énorme, ils prendront alors beaucoup de place et, probablement, cela ne sera pas utile pour les petits appareils - ils n'ont toujours pas besoin de grands sprites, mais consommeront de la mémoire.

Je pense que cela suffit pour aujourd'hui. Il y a encore quelque chose à penser, comment mettre en œuvre ceci ou cela. En attendant, je m'éloigne un peu de la programmation et fais de la promotion. Je prévois de participer à une paire de vitrines indépendantes et je suis activement engagé dans la promotion du jeu sur les réseaux sociaux, car en novembre je prévois d'entrer sur la plateforme de financement participatif: j'aurai besoin de spécialistes dans le domaine du graphisme 3D et de l'animation squelettique pour terminer le jeu.

Dans les articles suivants, je parlerai des commandes tactiles dans les navigateurs pour appareils mobiles - tout le monde ne connecte pas une manette de jeu ou un clavier à la tablette, sur l'optimisation des graphiques 3D pour les appareils à faible consommation d'énergie, et bien plus encore.

Source: https://habr.com/ru/post/fr472272/


All Articles