Chapitre 13: Évènements du navigateur

Pour ajouter des fonctionnalités intéressantes à une page Web, être capable d’inspecter et de modifier un document est généralement suffisant. Nous avons également besoin de détecter ce que l’utilisateur est en train de faire et émettre une réponse en conséquence. Pour cela, nous utiliserons quelque chose nommé gestionnaire d’évènements. Les appuis sur des touches du clavier sont des évènements, les clics de souris sont des évènements, même les mouvements de souris peuvent être interprétés comme des séries d’évènements. Dans le chapitre 11, nous avons ajouté une propriété onclick à un bouton, dans le but de provoquer quelque chose lorsque ce bouton est actionné. Ceci est un gestionnaire d’évènement simple.

La manière dont les évènements du navigateur fonctionnent est fondamentalement très simple. Il est possible d’enregistrer des gestionnaires pour des types d’évènements et des nœuds DOM spécifiques. Quel que soit le moment de l’évènement, le gestionnaire de cet évènement, s’il existe, est appelé. Pour certains évènements, comme des touches de clavier pressées, le fait que l’évènement se soit produit n’est pas suffisant, il faut aussi savoir quelle touche a été pressée. Pour enregistrer cette information, chaque évènement crée un objet évènement, qui peut être analysé par le gestionnaire.

Il est important de noter que si des évènements peuvent apparaître à tout moment, deux gestionnaires d’évènements ne vont pas fonctionner en même temps. Si du code JavaScript est encore en train de fonctionner, le navigateur va attendre que celui-ci se termine avant d’appeler le gestionnaire suivant. Cela vaut aussi pour du code qui est déclenché d’une autre manière, comme avec setTimeout. Dans le jargon de la programmation, le navigateur JavaScript gère une tâche unique à la fois, il n’y a jamais deux tâches fonctionnant au même instant. Dans la plupart des cas, c’est une bonne chose. Il est très facile d’obtenir des résultats étranges quand plusieurs choses sont traitées au même instant.

Un évènement, quand il n’est pas géré, peut « remonter » à travers l’arborescence DOM. Cela signifie que si vous cliquez sur un lien dans un paragraphe, par exemple, n’importe quel gestionnaire associé avec le lien est appelé en premier. S’il n’y a pas de gestionnaire ou que ces gestionnaires n’indiquent pas qu’ils ont fini de traiter l’évènement en question, les gestionnaires d’évènements liés au paragraphe, qui est parent du lien, sont appelés. Après cela, les gestionnaires de document.body sont invoqués. Finalement, si aucun gestionnaire JavaScript ne s’est occupé de cet évènement, le navigateur le gère. Quand on clique sur le lien, cela signifie que le lien va être suivi.


Donc comme vous pouvez le voir, les évènements sont simples. La seule chose compliquée à leur propos est que, bien que les navigateurs prennent en charge tous plus ou moins la même fonctionnalité, ils le font par le biais d’interfaces différentes. Comme d’habitude, le navigateur le plus incompatible est Internet Explorer, qui ignore les standards respectés par la plupart des autres navigateurs. En seconde position vient Opera, qui ne gère pas correctement quelques évènements utiles, tels que l’évènement onunload qui se produit quand on quitte une page, ou bien qui retourne parfois des informations peu claires à propos des évènements du clavier.

Il existe quatre actions associées aux évènements que l’on peut vouloir invoquer.

Aucun d’eux ne fonctionne de la même manière sur tous les principaux navigateurs.


Comme exercice d’entraînement pour notre gestion d’évènements, nous allons ouvrir un document avec un bouton et un champ de texte. Laissez cette fenêtre ouverte (et liée) pour le reste du chapitre.

attach(window.open("example_events.html"));

La première action, enregistrement d’un gestionnaire d’évènement, peut être réalisée en définissant la propriété onclick (ou onkeypress, ou…) d’un élément. Cela fonctionne pour tous les navigateurs, mais il existe un inconvénient majeur à faire cela : vous ne pouvez définir qu’un seul gestionnaire pour un élément. La plupart du temps, un gestionnaire est suffisant, mais il existe certains cas, spécialement quand un programme doit fonctionner avec d’autres programmes (qui peuvent également ajouter leurs propres gestionnaires), où cela peut être ennuyeux.

Dans Internet Explorer, on peut ajouter un gestionnaire de clic sur un bouton de cette façon :

$("bouton").attachEvent("onclick", function(){print("Clic !");});

Dans les autres navigateurs, cela fonctionne de cette façon:

$("bouton").addEventListener("click", function(){print("Clic !");},
                             false);

Remarquez comment "on" est laissé de côté dans le second cas. Le troisième argument d’addEventListener, false, indique que l’évènement doit « remonter » normalement à travers l’arborescence DOM. Définir le troisième argument à true permet de rendre le gestionnaire prioritaire sur les gestionnaires « sous » lui, mais comme Internet Explorer ne prend pas en charge un tel mécanisme, il est rarement utilisé.


Ex. 13.1

Écrire une fonction nommée registerEventHandler pour encapsuler les incompatibilités des deux modèles. Cette fonction possède trois arguments : un nœud DOM auquel le gestionnaire doit être attaché, le nom du type de l’évènement, comme "click" ou "keypress", et enfin la fonction qui va assurer la gestion de l’évènement.

Pour déterminer quelle méthode doit être appelée, recherchez les méthodes elles-mêmes ― si le nœud DOM possède une méthode appelée attachEvent, vous pouvez supposer que c’est la bonne méthode. Remarquez qu’il est largement préférable de faire cela que de vérifier directement si le navigateur est Internet Explorer. En effet, si un nouveau navigateur, qui utilise le modèle d’Internet, apparaît, ou si Internet Explorer passe tout d’un coup au modèle standard, le code continuera à fonctionner. Les deux sont peu probables, bien sûr, mais faire quelque chose intelligemment n’a jamais causé de dégâts.

function registerEventHandler(noeud, event, handler) {
  if (typeof noeud.addEventListener == "function")
    noeud.addEventListener(event, handler, false);
  else
    noeud.attachEvent("on" + event, handler);
}

registerEventHandler($("bouton"), "click",
                     function(){print("Clic (2)");});

Ne vous inquiétez pas du nom maladroit et à rallonge. Plus tard, nous devrons ajouter un adaptateur supplémentaire pour encapsuler cet adaptateur, et il aura un nom plus court.

Il est également possible de faire la vérification une seule fois, et de définir registerEventHandler de façon à contenir une fonction différente selon le navigateur. C’est plus efficace même si c’est un peu bizarre.

if (typeof document.addEventListener == "function")
  var registerEventHandler = function(noeud, event, handler) {
    noeud.addEventListener(event, handler, false);
  };
else
  var registerEventHandler = function(noeud, event, handler) {
    noeud.attachEvent("on" + event, handler);
  };

Supprimer des évènements fonctionne quasiment comme en ajouter, mais cette fois, on utilise les méthodes detachEvent et removeEventListener. N’oubliez pas cela : pour supprimer un gestionnaire, vous devez avoir accès à la fonction que vous y avez attachée.

function unregisterEventHandler(noeud, event, handler) {
  if (typeof noeud.removeEventListener == "function")
    noeud.removeEventListener(event, handler, false);
  else
    noeud.detachEvent("on" + event, handler);
}

Les exceptions produites par les gestionnaires d’évènements ne peuvent pas, à cause de limitations techniques, être récupérées par la console. Elles sont donc gérées par le navigateur, ce qui veut dire qu’elles peuvent être cachées quelque part dans une sorte de « console d’erreur », ou bien faire un apparaître un message. Lorsque vous écrivez un gestionnaire d’évènements et qu’il ne semble pas fonctionner, il peut s’arrêter de fonctionner silencieusement, car il cause une erreur quelconque.


La plupart des navigateurs passent l’objet évènement en argument du gestionnaire. Internet Explorer le stocke dans une variable globale appelé event. Lorsque vous regarderez du code JavaScript, vous tomberez souvent sur quelque chose comme event || window.event, qui prend la variable locale event, ou, si elle est définie, la variable globale du même nom.

function showEvent(event) {
  show(event || window.event);
}

registerEventHandler($("champtexte"), "keypress", showEvent);

Tapez quelques caractères dans le champ, regardez les objets, et débarrassez-vous en :

unregisterEventHandler($("champtexte"), "keypress", showEvent);

Quand l’utilisateur clique avec sa souris, trois évènements sont générés. En premier mousedown, au moment où le bouton est appuyé. Puis mouseup, au moment où il est relâché. Et enfin click, pour indiquer que quelque chose a été cliqué. Quand cela se répéte deux fois rapidement, un évènement dblclick (double-clic) est également généré. Remarquez bien qu’il est possible que les évènements mousedown et mouseup se produisent avec un certain délai entre les deux ― lorsque le bouton de la souris est maintenu enfoncé pendant un certain temps.

Lorsque vous attachez un gestionnaire d’évènements, par exemple, à un bouton, le fait qu’il a été cliqué est souvent la seule chose que vous avez besoin de savoir. Lorsque le gestionnaire, d’un autre côté, est attaché à un nœud qui a des fils, les clics sur les fils vont « remonter » vers lui, et vous voudrez savoir quel fils a été cliqué. Dans ce but, les objets évènements ont une propriété nommée target… ou srcElement, en fonction du navigateur.

Une autre information intéressante concerne les coordonnées précises auxquelles le clic s’est produit. Les objets évènements concernant la souris contiennent les propriétés clientX and clientY, qui donnent les coordonnées x et y de la souris à l’écran, en pixels. Les documents peuvent défiler, ces informations ne nous donnent donc souvent pas beaucoup d’informations sur la partie du document au-dessus de laquelle se trouve la souris. Certains navigateurs fournissent les propriétés pageX et pageY dans ce but, mais d’autres (devinez lesquelles) ne les fournissent pas. Heureusement, l’information de la quantité de pixels du document qui a déjà défilé se trouve dans document.body.scrollLeft et document.body.scrollTop.

Ce gestionnaire, attaché au document entier, intercepte tous les clics de souris, et enregistre quelques informations à leur sujet.

function afficherClic(event) {
  event = event || window.event;
  var elementConcerne = event.target || event.srcElement;
  var pageX = event.pageX, pageY = event.pageY;
  if (pageX == undefined) {
    pageX = event.clientX + document.body.scrollLeft;
    pageY = event.clientY + document.body.scrollTop;
  }

  print("Clic de souris en position ", pageX, ", ", pageY,
        ". Elément concerné:");
  show(elementConcerne);
}
registerEventHandler(document, "click", afficherClic);

Et débarrassez-vous en de nouveau :

unregisterEventHandler(document, "click", afficherClic);

Évidemment, écrire toutes ces vérifications et ces solutions de contournement n’est pas quelque chose que vous avez envie de faire dans tous les gestionnaires d’évènements. Dans quelques instants, après avoir fait connaissance avec quelques incompatibilités supplémentaires, nous allons écrire une fonction pour « normaliser » les objets évènements afin qu’ils fonctionnent de la même manière sur tous les navigateurs.

Il est également parfois possible de déterminer quel bouton de la souris a été appuyé, en utilisant les propriétés which et button des objets évènement. Malheureusement, on ne peut pas leur faire confiance : certains navigateurs prétendent que les souris n’ont qu’un bouton, d’autres signalent les clics droits comme des clics avec la touche control appuyée, et ainsi de suite.


En dehors des clics, on peut également être intéressé par les mouvements de la souris. L’évènement mousemove d’un nœud DOM se produit dès que la souris bouge lorsqu’elle est sur cet élément. Il y a aussi les évènements mouseover et mouseout, qui se produisent uniquement lorsque la souris entre dans un nœud ou le quitte. Pour les évènements du second type, la propriété target (ou srcElement) indique le nœud pour lequel l’évènement s’est produit, alors que la propriété relatedTarget (ou toElement, ou fromElement) indique le nœud d’où provient (pour mouseover) ou vers lequel se dirige la souris (pour mouseout).

mouseover et mouseout peuvent être embêtants quand ils sont enregistrés sur un élément qui a des nœuds-fils. Les évènements se produisant dans les nœuds-fils vont remonter vers l’élément parent, donc vous allez également recevoir un évènement mouseover quand la souris entre dans l’un des nœuds-fils. Les propriétés target et relatedTarget peuvent être utilisées pour détecter (ou ignorer) de tels évènements.


Pour chaque touche pressée par l’utilisateur, trois évènements sont générés: keydown, keyup, et keypress. En général, vous devez utiliser les deux premiers dans les cas où vous voulez vraiment savoir quelle touche a été pressée, par exemple lorsque vous voulez faire quelque chose lorsque les touches de direction sont pressées. keypress, d’un autre côté, doit être utilisé lorsque vous êtes intéressé par le caractère qui est tapé. La raison de cela est qu’il n’y a souvent aucune information de caractère dans les évènements keyup et keydown, et Internet Explorer ne génère aucun évènement keypress pour les touches spéciales comme les touches de direction.

Déterminer quelle touche a été pressée peut être un défi en soi. Pour les évènements keydown et keyup, l’objet évènement va posséder une propriété keyCode qui contient un nombre. La plupart du temps, ces codes peuvent être utilisés pour identifier les touches d’une façon plutôt indépendante du navigateur. Déterminer quel code correspond à quelle touche peut être réalisé avec quelques simples expériences.

function afficherCodeTouche(event) {
  event = event || window.event;
  print("La touche ", event.keyCode, " a été pressée.");
}

registerEventHandler($("champtexte"), "keydown", afficherCodeTouche);
unregisterEventHandler($("champtexte"), "keydown", afficherCodeTouche);

Dans la plupart des navigateurs, un code de touche unique correspond à une touche physique unique sur votre clavier. Le navigateur Opera, toutefois, va générer des codes différents pour certaines touches en fonction du fait que la touche Maj est appuyée ou non. Pire encore, certains de ces codes shift-est-appuyé sont des codes qui sont également utilisés pour d’autres touches ― Maj-9, qui dans la plupart des claviers QWERTY est utilisé pour taper une parenthèse, reçoit le même code que touche de direction bas, et il est donc difficile de distinguer les deux. Lorsque cela risque de saboter vos programmes, vous pouvez en général résoudre le problème en ignorant les évènements d’appui de touche avec la touche Maj pressée.

Pour savoir si les touches shift, control ou alt sont appuyées lors d’un évènement touche ou souris, vous pouvez regarder les propriétés shiftKey, ctrlKey, et altKey de l’objet évènement.

Pour les évènements keypress, vous voudrez savoir quel caractère a été tapé. L’objet évènement aura une propriété charCode, qui, si vous êtes chanceux, contiendra la valeur Unicode correspondant au caractère qui a été tapé, qui peut être converti en une chaîne à 1 seul caractère en utilisant String.fromCharCode. Malheureusement, certains navigateurs ne définissent pas cette propriété, ou la définissent à 0, et stockent à la place le code du caractère dans la propriété keyCode.

function afficherCaractere(event) {
  event = event || window.event;
  var codeCaractere = event.charCode;
  if (codeCaractere == undefined || codeCaractere === 0)
    codeCaractere = event.keyCode;
  print("Caractère '", String.fromCharCode(codeCaractere), "'");
}

registerEventHandler($("champtexte"), "keypress", afficherCaractere);
unregisterEventHandler($("champtexte"), "keypress", afficherCaractere);

Un gestionnaire d’évènements peut « arrêter » l’évènement qu’il est en train de gérer. Il y a deux façons de faire cela. Vous pouvez empêcher l’évènement de remonter dans les nœuds parents et les gestionnaires qui ont été définis pour eux, et vous pouvez empêcher le navigateur de réaliser les actions standards associés à un tel évènement. Il est important de noter que les navigateurs ne vont pas forcément suivre vos instructions ― empêcher les comportements par défaut lorsque l’utilisateur appuie sur certaines touches spéciales n’empêchera pas les navigateurs, pour la plupart d’entre eux, d’exécuter l’effet normal de ces touches.

Dans la plupart des navigateurs, arrêter la remontée d’un évènement est réalisé en utilisant la méthode stopPropagation de l’objet évènement, et empêcher le comportement par défaut est réalisé grâce à la méthode preventDefault. Pour Internet Explorer, on le fait en définissant respsectivement la propriété cancelBubble à true et la propriété returnValue à false.

Et c’était la dernière d’une longue liste d’incompatibilités dont nous discuterons dans ce chapitre. Cela veut donc dire que nous pouvons écrire la fonction de normalisation d’évènements et passer à des choses plus intéressantes.

function normaliseEvent(event) {
  if (!event.stopPropagation) {
    event.stopPropagation = function() {this.cancelBubble = true;};
    event.preventDefault = function() {this.returnValue = false;};
  }
  if (!event.stop) {
    event.stop = function() {
      this.stopPropagation();
      this.preventDefault();
    };
  }

  if (event.srcElement && !event.target)
    event.target = event.srcElement;
  if ((event.toElement || event.fromElement) && !event.relatedTarget)
    event.relatedTarget = event.toElement || event.fromElement;
  if (event.clientX != undefined && event.pageX == undefined) {
    event.pageX = event.clientX + document.body.scrollLeft;
    event.pageY = event.clientY + document.body.scrollTop;
  }
  if (event.type == "keypress") {
    if (event.charCode === 0 || event.charCode == undefined)
      event.character = String.fromCharCode(event.keyCode);
    else
      event.character = String.fromCharCode(event.charCode);
  }

  return event;
}

Une méthode stop a été ajoutée, qui annule à la fois la remontée des évènements et leur action par défaut. Certains navigateurs le proposent déjà, dans ce cas nous le laissons tel quel.

Ensuite, nous pouvons écrire des adaptateurs pratiques pour registerEventHandler et unregisterEventHandler :

function addHandler(noeud, type, handler) {
  function handlerAvecNormalisation(event) {
    handler(normaliseEvent(event || window.event));
  }
  registerEventHandler(noeud, type, handlerAvecNormalisation);
  return {noeud: noeud, type: type, handler: handlerAvecNormalisation};
}

function removeHandler(objet) {
  unregisterEventHandler(objet.noeud, objet.type, objet.handler);
}

var blocageLettreQ = addHandler($("champtexte"), "keypress", function(event) {
  if (event.character.toLowerCase() == "q")
    event.stop();
});

La nouvelle fonction addHandler encapsule dans une nouvelle fonction la fonction du gestionnaire qui lui est donnée, ce qui lui permet de s’occuper de la normalisation des objets évènement. Il retourne un objet qui peut être passé à removeHandler lorsque l’on veut supprimer ce gestionnaire précis. Essayez de taper un q dans le champ texte.

removeHandler(blocageLettreQ);

Armé de addHandler et de la fonction dom du chapitre précédent, nous sommes prêts pour des possibilités plus ambitieuses de manipulation de document. Pour s’exercer, nous allons implémenter le jeu connu sous le nom de Sokoban. C’est un classique, mais vous ne l’avez peut-être jamais vu auparavant. Les règles sont les suivantes : on a une grille, faite de murs, d’espaces vides et d’une ou plusieurs « sorties ». Sur cette grille, il y a un certain nombre de caisses ou de pierres, et un petit bonhomme que le joueur contrôle. Ce bonhomme peut être déplacé horizontalement et verticalement dans les espaces vides, et peut pousser les rochers, à condition qu’il y ait un espace vide derrière eux. Le but du jeu et de déplacer un nombre donné de rochers vers les sorties.

Tout comme les terraria du chapitre 8, un niveau de Sokoban peut être représenté sous forme de texte. La variable niveauxSokoban, dans la fenêtre example_events.html, contient un tableau d’objets "niveau". Chaque niveau a une propriété terrain, qui contient une représentation textuelle du niveau, et une propriété rochers, qui indique la quantité de rochers qui doivent être expulsés pour finir le niveau.

show(niveauxSokoban.length);
show(niveauxSokoban[1].rochers);
forEach(niveauxSokoban[1].terrain, print);

Dans un niveau de ce type, les caractères # sont des murs, les espaces sont des cases vides, les caractères 0 sont utilisés pour les rochers, un @ pour la position de départ du joueur et un * pour la sortie.


Mais lorsque l’on joue, on ne veut pas voir cette représentation textuelle. À la place, nous allons mettre un tableau dans le document. J’ai fait une petite feuille de style (sokoban.css si vous êtes curieux de savoir à quoi elle ressemble) pour donner une taille fixe aux cellules de ce tableau, et ajouté un document d’exemple. Chacune des cellules du tableau va recevoir une image de fond, représentant le type de case (vide, mur ou sortie). Pour montrer la position du joueur et des rochers, des images sont ajoutées à ces cellules et déplacées dans d’autres cellules en fonction du besoin.

On pourrait utiliser ce tableau comme représentation principale de nos données : pour savoir s’il y a un mur dans une case donnée, il suffit de regarder l’image de fond de la cellule appropriée du tableau, et pour trouver le joueur, il suffit de chercher un nœud image avec la propriété src correcte. Dans certains cas, cette approche est pratique, mais pour ce programme, j’ai choisi de conserver une structure de donnés séparée pour la grille, car cela rend les choses plus claires.

Cette structure de données est une grille d’objets à deux dimensions, représentant les cases de l’aire de jeu. Chacun des objets doit stocker le type d’arrière-plan qu’il possède et un rocher ou le joueur présent dans cette case. Il doit aussi contenir une référence vers la cellule du tableau qui est utilisée pour l’afficher dans le document, pour faciliter le déplacement d’images dans et hors de la cellule du tableau.

Cela nous donne deux types d’objets : un pour gérer la grille de l’aire de jeu, et un pour représenter les cellules individuelles de la grille. Si nous voulons aussi que le jeu soit capable de faire des choses comme passer au niveau suivant au bon moment, et offrir la possibilité de réinitialiser le niveau en cours si vous vous êtes loupé, nous aurons également besoin d’un objet « contrôleur », qui crée et supprime les objets aire de jeu au moment approprié. Par commodité, nous utiliserons l’approche par prototype que nous avons décrit à la fin du chapitre 8, les types d’objet sont donc seulement des prototypes, et on utilise la méthode create, plutôt que l’opérateur new, pour créer de nouveaux objets.


Commençons par les objets représentant les cases de l’aire de jeu. Ils sont chargés de la définition correcte de l’arrière-plan de leur cellule, et de l’ajout des images quand nécessaire. Le répertoire img/sokoban/ contient un ensemble d’images, basées sur un autre ancien jeu, qui seront utilisées pour visualiser le jeu. Pour commencer, le prototype Carreau peut ressembler à ça.

var Carreau = {
  construct: function(caractere, celluleDeTableau) {
    this.arrierePlan = "empty";
    if (caractere == "#")
      this.arrierePlan = "wall";
    else if (caractere == "*")
      this.arrierePlan = "exit";

    this.celluleDeTableau = celluleDeTableau;
    this.celluleDeTableau.className = this.arrierePlan;

    this.contenu = null;
    if (caractere == "0")
      this.contenu = "boulder";
    else if (caractere == "@")
      this.contenu = "player";

    if (this.contenu != null) {
      var image = dom("IMG", {src: "img/sokoban/" +
                                   this.contenu + ".gif"});
      this.celluleDeTableau.appendChild(image);
    }
  },

  aUnJoueur: function() {
    return this.contenu == "player";
  },
  aUnRocher: function() {
    return this.contenu == "boulder";
  },
  estVide: function() {
    return this.contenu == null && this.arrierePlan == "empty";
  },
  estUneSortie: function() {
    return this.arrierePlan == "exit";
  }
};

var carreauDeTest = Carreau.create("@", dom("TD"));
show(carreauDeTest.aUnJoueur());

L’argument caractere du constructeur est utilisé pour transformer les caractères du plan en objets Carreau réels. Pour définir l’arrière-plan des cellules, on utilise des classes de feuilles de styles (définies dans sokoban.css), qui sont assignées à la propriété className des éléments td.

Les méthodes comme aUnJoueur et estVide sont une façon « d’isoler » le code qui utilise les objets de ce type, du fonctionnement interne des objets. Ce n’est pas absolument nécessaire dans ce cas, mais cela permettra de rendre le reste du code meilleur.


Ex. 13.2

Ajoutez les méthodes deplaceContenu et effaceContenu au prototype Carreau. Le premier prend un autre objet Carreau en argument, et déplace le contenu de la case this dans cet objet en mettant à jour les propriétés contenu et en déplaçant le nœud image associé au contenu. Cette méthode sera utilisée pour déplacer les rochers et le joueur à travers la grille. Elle peut supposer que la case n’est pas vide au moment de l’appel. effaceContenu supprime le contenu d’une case sans le déplacer nulle part. Notez bien que la propriété contenu pour les cases vides contient null.

La fonction removeElement que nous avons définie au chapitre 12 est également disponible dans ce chapitre, pour vos besoins de suppression de nœud. Vous pouvez supposer que les images sont les seuls nœuds-fils des cellules de la table, et peuvent donc, par exemple, être atteints avec this.celluleDeTableau.lastChild.

Carreau.deplaceContenu = function(carreauCible) {
  carreauCible.contenu = this.contenu;
  this.contenu = null;
  carreauCible.celluleDeTableau.appendChild(this.celluleDeTableau.lastChild);
};
Carreau.effaceContenu = function() {
  this.contenu = null;
  removeElement(this.celluleDeTableau.lastChild);
};

Le type d’objet suivant sera appelé TerrainSokoban. On passe à son constructeur un objet du tableau niveauxSokoban, et il est en charge à la fois de la création d’une table de nœud DOM, mais également de la création d’une grille d’objets Carreau. Cet objet s’occupera également des détails pour déplacer le joueur et les rochers, grâce à une méthode move à laquelle on passe un argument indiquant dans quelle direction nous voulons déplacer le joueur.

Pour identifier les cases individuelles, et pour indiquer les directions, nous allons de nouveau utiliser le type d’objet Point du chapitre 8, qui, si vous vous en souvenez, a une méthode add.

La base du prototype de l’aire de jeu ressemblera à ça :

var TerrainSokoban = {
  construct: function(niveau) {
    var corpsDeTableau = dom("TBODY");
    this.carreaux = [];
    this.rochersRestants = niveau.rochers;

    for (var y = 0; y < niveau.terrain.length; y++) {
      var ligne = niveau.terrain[y];
      var rangeeDeTableau = dom("TR");
      var rangeeDeCarreaux = [];
      for (var x = 0; x < ligne.length; x++) {
        var celluleDeTableau = dom("TD");
        rangeeDeTableau.appendChild(celluleDeTableau);
        var carreau = Carreau.create(ligne.charAt(x), celluleDeTableau);
        rangeeDeCarreaux.push(carreau);
        if (carreau.aUnJoueur())
          this.positionDuJoueur = new Point(x, y);
      }
      corpsDeTableau.appendChild(rangeeDeTableau);
      this.carreaux.push(rangeeDeCarreaux);
    }

    this.table = dom("TABLE", {"class": "sokoban"}, corpsDeTableau);
    this.score = dom("DIV", null, "…");
    this.miseaJourScore();
  },

  lireCarreau: function(position) {
    return this.carreaux[position.y][position.x];
  },
  miseaJourScore: function() {
    this.score.firstChild.nodeValue = this.rochersRestants +
                                      " rochers restants.";
  },
  aGagne: function() {
    return this.rochersRestants <= 0;
  }
};

var terrainDeTest = TerrainSokoban.create(niveauxSokoban[0]);
show(terrainDeTest.lireCarreau(new Point(10, 2)).contenu);

Le constructeur lit chaque ligne et chaque caractère du niveau, et stocke les objets Carreau dans la propriété carreaux. Quand il rencontre une case avec le joueur, il enregistre cette position dans positionDuJoueur, pour qu’il soit facile de retrouver la case dans laquelle se trouve le joueur. lireCarreau est utilisé pour trouver l’objet Carreau à une position x,y donnée de l’aire de jeu. Remarquez que l’on ne tient pas compte des bords de la grille : pour éviter d’écrire du code ennuyeux, nous supposons que l’aire de jeu est correctement fermée par des murs, ce qui empêche le joueur d’en sortir.

Le mot "class" dans l’appel dom qui crée le nœud table est donné sous forme de chaîne . Cela est nécessaire car class est un mot réservé en JavaScript, et ne doit pas être utilisé pour une variable ou un nom de propriété.

Le nombre de rochers dont il faut se débarrasser pour réussir un niveau (ce nombre peut être inférieur au nombre total de rochers du niveau) est stocké dans rochersRestants. Chaque fois qu’un rocher est amené à la sortie, nous pouvons en soustraire 1, et voir si la partie est gagnée. Pour montrer au joueur comment il s’en sort, nous devrons afficher cette valeur quelque part. Dans ce but, on va utiliser un élément div avec du texte. Les nœuds div sont des conteneurs sans balise qui leur est propre. Le texte du score peut être mis à jour avec la méthode miseaJourScore. La méthode aGagne sera utilisée par l’objet contrôleur pour déterminer quand la partie est terminée, pour que le joueur puisse passer au niveau suivant.


Si nous voulons voir le terrain de jeu et le score, nous devrons l’insérer dans le document d’une manière ou d’une autre. C’est à cela que sert la méthode place. Nous allons aussi ajouter une méthode enlever pour faciliter la suppression d’un niveau quand on a terminé.

TerrainSokoban.place = function(ou) {
  ou.appendChild(this.score);
  ou.appendChild(this.table);
};
TerrainSokoban.enlever = function() {
  removeElement(this.score);
  removeElement(this.table);
};

terrainDeTest.place(document.body);

Si tout s’est bien passé, vous devriez maintenant voir un jeu de Sokoban.


Ex. 13.3

Mais ce niveau ne fait pas encore grand-chose. Ajoutez une méthode appelée deplacer. Elle prend en argument un objet Point décrivant le mouvement (par exemple -1,0 pour se déplacer vers la gauche), et s’occupe de déplacer les éléments correctement.

Voici la démarche correcte : la propriété positionDuJoueur peut être utilisée pour déterminer où le joueur essaye de se déplacer. S’il y a un rocher à cet endroit-là, regardez la case derrière ce rocher. S’il y a une sortie, enlevez le rocher et mettez à jour le score. S’il y a un espace vide, déplacez le rocher dans celui-ci et essayez ensuite de bouger le joueur. Si la case dans laquelle il essaie de se déplacer n’est pas vide, abandonnez le déplacement.

TerrainSokoban.deplacer = function(direction) {
  var carreauDuJoueur = this.lireCarreau(this.positionDuJoueur);
  var positionSouhaitee = this.positionDuJoueur.add(direction);
  var carreauSouhaite = this.lireCarreau(positionSouhaitee);

  // Tente de déplacer un rocher
  if (carreauSouhaite.aUnRocher()) {
    var carreauOuPousserUnRocher = this.lireCarreau(positionSouhaitee.add(direction));
    if (carreauOuPousserUnRocher.estVide()) {
      carreauSouhaite.deplaceContenu(carreauOuPousserUnRocher);
    }
    else if (carreauOuPousserUnRocher.estUneSortie()) {
      carreauSouhaite.deplaceContenu(carreauOuPousserUnRocher);
      carreauOuPousserUnRocher.effaceContenu();
      this.rochersRestants--;
      this.miseaJourScore();
    }
  }
  // Déplace le joueur
  if (carreauSouhaite.estVide()) {
    carreauDuJoueur.deplaceContenu(carreauSouhaite);
    this.positionDuJoueur = positionSouhaitee;
  }
};

En s’occupant des rochers en premier, le code de déplacement peut fonctionner de la même façon quand un joueur se déplace normalement et quand il pousse un rocher. Remarquez comment la case derrière est trouvée en ajoutant direction à positionDuJoueur deux fois. Faites un test avec un déplacement vers la gauche de deux cases :

terrainDeTest.deplacer(new Point(-1, 0));
terrainDeTest.deplacer(new Point(-1, 0));

Si cela a marché, on a déplacé un rocher dans un espace d’où on ne peut plus le retirer, donc on ferait mieux de se débarrasser de cette aire de jeu.

terrainDeTest.enlever();

On s’est occupé de toute la « logique du jeu » maintenant, et on a juste besoin d’un contrôleur pour que le jeu soit jouable. Le contrôleur sera un type d’objet appelé JeuSokoban, qui est responsable de ce qui suit :

On commence encore par un prototype inachevé.

var JeuSokoban = {
  construct: function(place) {
    this.niveau = null;
    this.terrain = null;

    var nouveauJeu = dom("BUTTON", null, "Nouvelle partie");
    addHandler(nouveauJeu, "click", method(this, "nouveauJeu"));
    var reinitialiserNiveau = dom("BUTTON", null, "Réinitialiser niveau");
    addHandler(reinitialiserNiveau, "click", method(this, "reinitialiserNiveau"));
    this.container = dom("DIV", null,
                         dom("H1", null, "Sokoban"),
                         dom("DIV", null, nouveauJeu, " ", reinitialiserNiveau));
    place.appendChild(this.container);

    addHandler(document, "keydown", method(this, "touchePressee"));
    this.nouveauJeu();
  },

  nouveauJeu: function() {
    this.niveau = 0;
    this.reinitialiserNiveau();
  },
  reinitialiserNiveau: function() {
    if (this.terrain)
      this.terrain.enlever();
    this.terrain = TerrainSokoban.create(niveauxSokoban[this.niveau]);
    this.terrain.place(this.container);
  },

  touchePressee: function(event) {
    // à compléter
  }
};

Le constructeur construit un élément div pour stocker l’aire de jeu, avec deux boutons et un titre. Remarquez comment method est utilisé pour attacher les méthodes de l’objet this à des évènements.

On peut mettre un jeu Sokoban dans notre document de cette façon :

var sokoban = JeuSokoban.create(document.body);

Ex. 13.4

Tout ce qu’il reste à faire maintenant c’est de remplir le gestionnaire d’évènements clavier. Remplacez la méthode touchePressee du prototype par une autre qui détecte les appuis sur les touches des flèches de déplacement, et quand elle les trouve, déplace le joueur dans la bonne direction. Dictionary ci-dessous sera probablement utile :

var codesTouchesFleches = new Dictionary({
  37: new Point(-1, 0), // gauche
  38: new Point(0, -1), // haut
  39: new Point(1, 0),  // droite
  40: new Point(0, 1)   // bas
});

Après qu’un appui sur une touche de direction est géré, vérifiez this.terrain.aGagne() pour savoir si c’était le déplacement gagnant. Si le joueur a gagné, utilisez alert pour afficher un message, et passer au niveau suivant. S’il n’y a pas de niveau suivant (vérifiez niveauxSokoban.length), redémarrez le jeu.

Il est probablement sage d’arrêter les évènements quand des appuis sur les touches ont été gérés , sinon les appuis sur les flèches « haut » et « bas » feront défiler votre fenêtre, ce qui est plutôt gênant.

JeuSokoban.touchePressee = function(event) {
  if (codesTouchesFleches.contains(event.keyCode)) {
    event.stop();
    this.terrain.deplacer(codesTouchesFleches.lookup(event.keyCode));
    if (this.terrain.aGagne()) {
      if (this.niveau < niveauxSokoban.length - 1) {
        alert("Excellent ! Passons au niveau suivant.");
        this.niveau++;
        this.reinitialiserNiveau();
      }
      else {
        alert("Vous avez gagné ! Partie terminée.");
        this.nouveauJeu();
      }
    }
  }
};

Vous devez avoir conscience que capturer des touches de cette manière (ajouter un gestionnaire d’évènements à document et stopper les évènements que vous recherchez) n’est pas très élégant quand il y a d’autres éléments dans le document. Par exemple, essayez de déplacer le curseur autour de la zone de texte en haut du document : cela ne fonctionne pas, vous allez juste déplacer le petit bonhomme dans le jeu Sokoban. Si un jeu comme celui-ci devait être utilisé dans un vrai site, il serait probablement mieux de le mettre dans une frame ou dans sa propre fenêtre, de façon à ce qu’il ne récupère que les évènements de sa propre fenêtre.


Ex. 13.5

Quand ils sont amenés à la sortie, les rochers disparaissent plutôt brusquement. En modifiant la méthode Carreau.effaceContenu, essayez d’afficher une animation de rochers « tombants » lorsqu’ils sont sur le point d’être enlevés. Faites-les rapetisser un moment avant, puis disparaître. Vous pouvez utiliser style.width = "50%", et de la même façon style.height, pour afficher une image à, par exemple, la moitié de sa taille habituelle.

On peut utiliser setInterval pour gérer le déroulement de l’animation. N’oubliez pas que la méthode doit s’assurer que les exécutions à intervalle régulier sont désactivées à la fin de l’animation. Si vous ne le faites pas, elles vont continuer à faire perdre du temps à votre ordinateur jusqu’à ce que la page soit fermée.

Carreau.effaceContenu = function() {
  self.contenu = null;
  var image = this.celluleDeTableau.lastChild;
  var size = 100;

  var deroulementAnimation = setInterval(function() {
    size -= 10;
    image.style.width = size + "%";
    image.style.height = size + "%";

    if (size < 60) {
      clearInterval(deroulementAnimation);
      removeElement(image);
    }
  }, 70);
};

Maintenant, si vous avez un peu de temps à perdre, essayez de finir tous les niveaux.


D’autres types d’évènements qui peuvent être utiles sont les évènements focus et blur, qui sont générés sur des éléments qui peuvent recevoir le « focus », par exemple les champs de saisie d’un formulaire. focus, évidemment, se produit lorsque vous donnez le focus à un élément, par exemple en cliquant dessus. blur est le terme JavaScript pour « enlever le focus », et il est généré quand le focus est retiré d’un élément.

addHandler($("champtexte"), "focus", function(event) {
  event.target.style.backgroundColor = "yellow";
});
addHandler($("champtexte"), "blur", function(event) {
  event.target.style.backgroundColor = "";
});

Un autre évènement lié aux entrées d’un formulaire est change. Il est généré quand le contenu d’une zone de saisie change… excepté pour certaines zones de saisie, comme les zones de texte, qui ne génèrent pas cet évènement avant que l’élément perde le focus.

addHandler($("champtexte"), "change", function(event) {
  print("Contenu de la zone de texte changé en '",
        event.target.value, "'.");
});

Vous pouvez taper ce que vous voulez, l’évènement ne sera généré que lorsque vous cliquerez en dehors de la zone de texte, appuierez sur la touche tabulation, ou enlèverez le focus de l’élément d’une autre façon.

Les formulaires ont également un évènement submit , qui est généré quand ils sont soumis. Il peut être stoppé pour empêcher la soumission d’avoir lieu. Cela nous donne une façon vraiment meilleure de valider le formulaire que celle que nous avons présentée dans le chapitre précédent. Vous enregistrez simplement un gestionnaire d’évènements pour submit qui arrête l’évènement si le contenu du formulaire n’est pas valide. De cette façon, lorsque JavaScript n’est pas activé pour l’utilisateur , le formulaire va continuer de fonctionner, il n’y aura tout simplement pas de validation instantanée.

Les objets Window ont un évènement load qui est généré lorsque le document est complètement chargé, ce qui peut être utile si votre script doit réaliser une initialisation quelconque qui doit attendre que tout le document soit présent. Par exemple, les scripts sur les pages de ce livre parcourent le chapitre en cours pour cacher les solutions des exercices. Vous ne pouvez pas le faire si les exercices ne sont pas encore chargés. Il existe également un évènement unload, qui est généré lorsque l’utilisateur quitte le document, mais il n’est pas correctement pris en charge par tous les navigateurs.

La plupart du temps, il est préférable de laisser la gestion de la mise en page du document au navigateur, mais il existe certains effets qui ne peuvent être réalisés qu’avec un peu de JavaScript pour définir la taille précise de certains nœuds d’un document. Quand vous faites cela, assurez-vous que vous surveillez les évènements resize de la fenêtre, et recalculez la taille de vos éléments chaque fois que la fenêtre change de taille.


Pour terminer, je dois vous dire quelque chose à propos des gestionnaires d’évènements que vous préféreriez ne pas savoir. Le navigateur Internet Explorer (c’est-à-dire, à l’heure où j’écris ceci, le navigateur de la majorité des internautes) souffre d’un bug qui empêche les valeurs d’être nettoyées correctement : même lorsqu’elles ne sont plus utilisées, elles restent dans la mémoire de l’ordinateur. Ceci est connu sous le nom de fuite mémoire et lorsque suffisamment de mémoire a fui, cela peut ralentir fortement un ordinateur.

Quand est-ce que cette fuite se produit ? À cause d’un défaut dans le ramasse-miettes d’Internet Explorer, le système dont le but est de récupérer les valeurs inutilisées, lorsque que vous avez un nœud DOM qui, à travers une de ses propriétés ou d’une façon plus indirecte, fait référence à un objet JavaScript normal, et que cet objet, en retour, fait référence à ce nœud DOM, aucun des deux objets ne sera ramassé par le ramasse-miettes. Cela vient du fait que les nœuds DOM et les autres objets JavaScript sont ramassés par différents systèmes : le système qui s’occupe de nettoyer les nœuds DOM fera attention de laisser tous les nœuds qui sont encore référencés par des objets JavaScript, et vice-versa pour le système qui ramasse les valeurs JavaScript normales.

Comme la description ci-dessus le montre, ce problème n’est pas spécifique aux gestionnaires d’évènements. Ce code, par exemple, crée un peu de mémoire qui ne peut pas être récupérée :

var unObjetJavaScript = {lien: document.body};
document.body.lienRetour = unObjetJavaScript;

Même si un navigateur Internet Explorer passe à la page suivante, il continue à garder ce document.body. La raison pour laquelle ce bug est souvent associé aux gestionnaires d’évènements vient du fait qu’il est extrêmement facile de créer de tels liens circulaires lorsque l’on enregistre un gestionnaire d’évènement. Le nœud DOM conserve une référence sur ses gestionnaires d’évènements, et le gestionnaire, la plupart du temps, possède une référence vers le nœud DOM. Même lorsque cette référence n’est pas faite intentionnellement, les règles de portée de JavaScript ont tendance à l’ajouter implicitement. Étudions cette fonction :

function addAlerter(element) {
  addHandler(element, "click", function() {
    alert("Alerte! ALERTE!");
  });
}

La fonction anonyme qui est créée par la fonction addAlerter peut "voir" la variable element. Elle ne l’utilise pas, mais cela n’a pas d’importance : simplement parce qu’elle peut la voir, elle possède une référence dessus. En enregistrant cette fonction comme un gestionnaire d’évènement pour ce même objet element, nous avons créé un cercle.

Il existe trois manières de régler ce problème. La première approche, qui est très populaire, est de l’ignorer. La plupart des scripts vont fuir très peu, il faudra donc beaucoup de temps et de pages avant que le problème se remarque. Et, quand les problèmes sont aussi subtils, qui vous considérera comme responsable ? Les programmeurs adeptes de cette approche dénonceront souvent vertement Microsoft pour cette programmation de mauvaise qualité, et déclareront que le problème n’est pas de leur faute, donc que ce n’est pas à eux de le réparer.

Un tel raisonnement ne manque pas de logique, bien sûr. Mais quand la moitié des utilisateurs ont un problème avec les pages que vous faites, il est difficile de nier qu’il existe un problème pratique. C’est pourquoi les personnes travaillant sur les sites « de qualité » essaient en général d’éviter les fuites mémoire. Ce qui nous amène à la deuxième approche : vérifier laborieusement qu’on ne crée pas de références circulaires entre les objets DOM et les objets normaux. Cela veut dire, par exemple, récrire le gestionnaire défini précédemment de cette façon :

function addAlerter(element) {
  addHandler(element, "click", function() {
    alert("Alerte! ALERTE!");
  });
  element = null;
}

Maintenant la variable element ne pointe plus sur le nœud DOM, et le gestionnaire n’aura pas de fuite mémoire. Cette approche est correcte, mais le programmeur doit vraiment faire très attention.

En définitive la troisième solution consiste à ne pas trop s’en faire si l’on crée des structures qui ont des fuites, mais à s’assurer qu’on a bien tout nettoyé lorsqu’on a terminé de les élaborer. Ce qui implique de dés-enregistrer les gestionnaires d’évènements quand on n’en a plus besoin, et d’enregistrer un évènement onunload pour dés-enregistrer les gestionnaires qui sont nécessaires jusqu’à ce que la page soit déchargée. Il est possible d’étendre un système d’enregistrement d’évènements, tel que notre fonction addHandler, pour automatiser le processus. En choisissant cette approche, vous devez garder à l’esprit que les gestionnaires d’évènements ne sont pas la seule source possible de fuite de mémoire ― ajouter des propriétés aux objets des nœuds du DOM peut causer des problèmes comparables.