Le comptoir du code

Les pérégrinations d'un développeur pragmatique

React Sokoban

By Mik | 22 octobre 2015 | 0 Comment

Le jeu

Cliquez sur le jeu pour activer le focus. Puis pour jouer, utilisez les touches I, J, K et L.

Le repo github sous licence GNU

Après Angular Bomber, voici React Sokoban, un nouveau jeu avec cette fois la technologie React.
Les carrés splendides de Angular Bomber m’ont rappelé un autre jeu auquel je jouais à l’époque sur HP48GX (l’ancêtre de l’iPhone pour les geeks de l’époque, on était toujours le nez dessus en classe).
Le jeu s’appelait « Garbage » sur ce calculateur, mais le nom populaire est « Sokoban ». Il s’agit d’un gars qui doit ranger un entrepôt de gros cartons, le problème étant qu’il ne peut que les pousser, pas les tirer, car ils sont trop lourds. Il faut donc faire le tour des cartons, et cela entraine un casse-tête pour le pauvre manoeuvre.

Côté techno

Comme pour Angular Bomber où je ne m’étais basé que sur Angular, ici je ne vais me baser que sur du React « pur ». Exit donc npm, bower, babel etc. Et on va préférer utiliser directement les React.createElement au JSX.
Cela permet d’avoir le code final au plus près de la machine, les mains dans le cambouis! Et profiter de React dans son plus simple appareil.

C’est parti pour les graphismes, toujours dans un style épuré et efficace 😉

react-sokoban

Le code html statique :

	<div class="game">
		<div class="tile hero" style="left:5%;top:5%;"></div>
		<div class="tile carton" style="left:10%;top:5%;"></div>
		<div class="tile target" style="left:15%;top:5%;"></div>
		<div class="tile wall" style="left:0%;top:0%;"></div>
		<div class="tile wall" style="left:5%;top:0%;"></div>
		<div class="tile wall" style="left:10%;top:0%;"></div>
		<div class="tile wall" style="left:15%;top:0%;"></div>
		<div class="tile wall" style="left:20%;top:0%;"></div>
		<div class="tile wall" style="left:0%;top:5%;"></div>
		<div class="tile wall" style="left:20%;top:5%;"></div>
		<div class="tile wall" style="left:0%;top:10%;"></div>
		<div class="tile wall" style="left:5%;top:10%;"></div>
		<div class="tile wall" style="left:10%;top:10%;"></div>
		<div class="tile wall" style="left:15%;top:10%;"></div>
		<div class="tile wall" style="left:20%;top:10%;"></div>
	</div>

Alors, pour comprendre :
En bleu, les murs de l’entrepôt.
En rouge, les cartons à pousser. Ici il n’y en a qu’un, il va falloir le pousser sur la droite, pour qu’il atterrisse sur le marquage au sol, en orange.
Et en vert et blanc, c’est le héros, notre manoeuvre!
Dans le cas de ce niveau, il n’a pas trop le choix, le seul mouvement possible est de pousser le carton vers la droite et de réussir le niveau 😉

Reactifions tout ça maintenant!

"use strict";

var Game = React.createClass({
    displayName: 'React Sokoban',
    render: function() {
    	return React.createElement(
		   "div",
		   { "className": "game" },
		   React.createElement("div", { "className": "tile hero", style: {left:"5%",top:"5%"} }),
		   React.createElement("div", { "className": "tile carton", style: {left:"10%",top:"5%"} }),
		   React.createElement("div", { "className": "tile target", style: {left:"15%",top:"5%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"0%",top:"0%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"5%",top:"0%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"10%",top:"0%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"15%",top:"0%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"20%",top:"0%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"0%",top:"5%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"20%",top:"5%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"0%",top:"10%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"5%",top:"10%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"10%",top:"10%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"15%",top:"10%"} }),
		   React.createElement("div", { "className": "tile wall", style: {left:"20%",top:"10%"} })
		);
    }
});

ReactDOM.render(
    React.createElement(Game),
    document.getElementById('game')
);

Ce qui donne :

react-sokoban-reactified

Pas beaucoup de changements, si ce n’est l’apparition des fameux attributs « data-reactid » dans le DOM.

Bien entendu, avec du JSX et un pré-processeur, on peut générer le code ci-dessus en gardant la syntaxe de notre HTML de départ. Ce qui est bien ici, c’est qu’il n’y a besoin de rien an amont, on copie/colle le code dans un navigateur et tout marche directement.
Cela permet aussi de bien comprendre où s’arrête React et où commence JSX.

La console du navigateur me dit :
Download the React DevTools for a better development experience:
https://fb.me/react-devtools

Ce que j’ai fait, sans résultat. Pas d’onglet React. Pourtant, je me rappelle l’avoir déjà vu marcher quelque part.
Mais passons, je n’en ai pas vraiment besoin. Restons pragmatique. Je vais rester dans un style « javascripto-assembleur » et me contenter de la console classique.

On va maintenant créer un modèle digne de ce nom.
J’ai décidé que le plateau de jeu sera modélisé par une chaîne de caractères, à l’ancienne.

Dans le cas de notre niveau, la chaîne serait (je vous épargne les guillemets et les retours à la ligne):

XXXXX
XHO.X
XXXXX

X est un mur, O un carton, . un marquage au sol. Un espace représente une case vide (occupée par aucun des éléments pré-cités).

Et c’est parti pour afficher notre level :

"use strict";

(function () {
	var TILE_CSS = {
		"H": "hero",
		"O": "carton",
		".": "target",
		"X": "wall",
		" ": ""
	};

	var getTileDim = function(level) {
    	var levelRows = level.split("\n");
		return {
			w: Math.min(10, 100 / levelRows[0].length),
			h: Math.min(10, 100 / levelRows.length)
		}
	};

	var Game = React.createClass({
	    displayName: 'React Sokoban',
	    render: function() {
	    	var levelRows = this.props.level.split("\n");
	    	var tileDim = getTileDim(level);
	        var createParams = ["div", {
	            "className": "game"
	        }];
	        var tile = undefined, rowIndex = undefined, colIndex = undefined,
	        	levelRow = undefined, tileType = undefined;
	        for (rowIndex = levelRows.length - 1; rowIndex &gt;= 0; rowIndex--) {
	        	levelRow = levelRows[rowIndex];
				for (colIndex = levelRow.length - 1; colIndex &gt;= 0; colIndex--) {
					tileType = levelRow[colIndex];
					tile = React.createElement("div", { "className": "tile " + TILE_CSS[tileType],
						style: {left:(tileDim.w*colIndex)+"%",top:(tileDim.h*rowIndex)+"%",
						height: tileDim.h+"%",width: tileDim.w+"%"} });
					createParams.push(tile);
				};
	        };
	        return React.createElement.apply(this, createParams);
	    }
	});

	var level = "" +
	    "XXXXX\n" +
	    "XHO.X\n" +
	    "XXXXX";

	ReactDOM.render(
	    React.createElement(Game, {level: level}),
	    document.getElementById('game')
	);
})(this);

J’en ai profité pour faire un petit IIFE pour ne pas polluer le namespace avec nos variables.

On voit tout de suite que sans JSX, le code n’est pas très esthétique.
Sur un « vrai projet », le JSX serait donc indispensable.
Personnellement, le JSX me fait un peu « bizarre », avec son côté « HTML dans du JS ».

« State » React et commandes de jeu

Le jeu se joue en déplaçant notre héros dans les 4 directions cardinales.
Pour cela, j’ai choisi les touches i,j,k,l car elles forment une croix et ne changent pas trop en fonction de la langue du clavier. J’évite de choisir les touches directionnelles (haut, bas, gauche, droite) car elles sont déjà utilisées par le navigateur.

	    getInitialState: function() {
			// impossible level - this should always been overriden by props
			return {
				level: "" +
			    "OX.\n" +
			    "XHX\n" +
			    ".XO"
			};
	    },
	    _onFilterKeyPress:function(e) {
	    	if (e.key === 'l') {
				var level = "" +
				    "XXXXX\n" +
				    "X HOX\n" +
				    "XXXXX";
	    		e.preventDefault();
    			this.setState({level: level}, function()  {
    				console.log("state changed");
    			}.bind(this));
	    	}
	    },
	    componentWillMount: function() {
	    	if (this.props !== undefined &amp;&amp; this.props.level !== undefined) {
	    		this.setState({level: this.props.level});
	    	}
	    },
	    render: function() {
	        var result = undefined, tile = undefined, rowIndex = undefined, colIndex = undefined,
	        	levelRow = undefined, tileType = undefined;
	    	var levelRows = this.state.level.split("\n");
	    	var tileDim = getTileDim(this.state.level);
	        var createParams = ["div", {
	            className: "game",
	            tabIndex: 1,
	            onKeyPress: this._onFilterKeyPress
	        }];
	        for (rowIndex = levelRows.length - 1; rowIndex &gt;= 0; rowIndex--) {
	        	levelRow = levelRows[rowIndex];
				for (colIndex = levelRow.length - 1; colIndex &gt;= 0; colIndex--) {
					tileType = levelRow[colIndex];
					tile = React.createElement("div", { "className": "tile " + TILE_CSS[tileType],
						style: {left:(tileDim.w*colIndex)+"%",top:(tileDim.h*rowIndex)+"%",
						height: tileDim.h+"%",width: tileDim.w+"%"} });
					createParams.push(tile);
				};
	        };
	        result = React.createElement.apply(this, createParams);
	        return result;
	    }

Alors, qu’a-t-on ajouté?
Pour savoir quand déclencher un render, React gère l’état d’un composant à l’instant t.
Cet état peut-être accédé via l’attribut state, et affecté via la fonction setState.

Evidemment, si notre modèle de données, la chaîne de caractères contenant le niveau, change, alors il faut re-render le jeu.

On a donc ajouté plusieurs choses :

  • une fonction React getInitialState qui permet d’être sûr d’avoir un état, même si on ne donne aucune valeur par défaut lors de la création du composant (ce que nous faisons via les props de React données lors du createElement dans ReactDOM.render, tout en bas du fichier)
  • une autre fonction React componentWillMount qui va nous permettre de faire le tout premier setState via les props données lors de la création du composant principal dans RenderDOM.render
  • la fonction render s’appuie maintenant sur state.level
  • on a également lié les touches à une fonction « mock » de changement d’état, sans réelle logique de jeu, pour tester le render. Notez le hack du tabIndex qui permet à la div de jeu de recevoir les événements de pression sur les touches.

On rafraichit, on prend le focus en cliquant sur le jeu, puis on appuie sur « L » et là miracle, le jeu s’update bien :

react-sokoban-state-update

Et voilà! Nous avons terminé le niveau. Le jeu ne le détecte pas encore, mais ça va venir 😉

Il n’y a plus qu’à faire la logique du jeu, qui dépend moins de React. On va remplacer la fonction « mock » lors de l’appui sur une touche et coder le jeu proprement dit.

Le résultat final n’est pas trop mal je trouve. Une petite journée de boulot tout de même (Edit : 2 en fait, j’ai rajouté des features, dont le principal est le chargement d’autres niveaux avec le format sokoban « international » lol). Je suis assez content de la logique de React, honnêtement mon impression est encore meilleure que celle d’Angular (même si React ne couvre pas les routes etc), qui fait plus « clunky ». D’ailleurs je me rappelle que quand j’ai utilisé Angular les premiers temps, je me suis dit au fond de moi qu’un re-render dépendant d’un état de l’application serait cool. C’est la philosophie de React, et cela permet d’optimiser les perfs en laissant le framework s’occuper de faire les diffs de DOM lui-même d’une « frame » à l’autre.

Mon avis après tout ça : une bonne impression pour React!

Le repo github sous licence GNU

TAGS

0 Comments

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *