Aller au contenu
Valmont

Compte rendu · Projet AJAX · BUT MMI 2

Les Bâtisseurs de Valmont

Le support écrit de notre jeu coopératif à deux joueurs : comment il fonctionne, du clic d'un joueur jusqu'à la base de données.

Auteurs
Ilies Ziane et Yael Yerro
Technologies
PHP orienté objet, MySQL, jQuery, AJAX
Lecture
Environ 28 minutes

Pour le professeur

Le code source du jeu

Tous les fichiers qui font fonctionner Les Bâtisseurs de Valmont : PHP, SQL, HTML, CSS, JavaScript et illustrations, dans un seul zip. Dans config.php, nous avons remplacé les identifiants de notre base par ID et MOT_DE_PASSE.

Télécharger le code source .zip · 19 fichiers · 387 Ko

Les donnent une définition au survol, au clavier ou au toucher.

Note au lecteur

Dès le départ, nous avons voulu que ce projet soit plus qu'un exercice. Notre envie, c'était de pouvoir le mettre dans notre portfolio, donc de le pousser le plus loin possible : une vraie ambiance, des animations, une carte du village qui se construit en direct. C'est pour ça que le jeu contient beaucoup de choses qui dépassent ce que demandait le sujet.

Un exemple ? Comme dans le jeu des allumettes, notre JavaScript envoie une demande au serveur avec $.ajax, et le PHP la récupère dans (chez nous $_REQUEST['action'], chez le professeur $_REQUEST['base']). Toutes les secondes, renvoie l'action get : c'est le , qui permet au joueur 1 de voir le coup du joueur 2 sans recharger la page. Le serveur répond toujours en avec json_encode(), et c'est un simple opérateur qui passe la main d'un joueur à l'autre, exactement comme dans allumettes.php. Nous avons aussi écrit nous-mêmes la connexion , les , et les classes Habitant et Batiment avec leur héritage.

Pour que ce soit clair, nous avons coupé ce support en deux. La Partie 1 présente les notions fondamentales, celles vues en cours : sans elles, le jeu ne marche pas. La Partie 2 présente les notions complémentaires, faites pour notre portfolio : sans elles, le jeu marche toujours, il est juste moins beau.

01

Contexte

Le sujet nous demandait de créer un jeu à deux joueurs en , qui se passe dans un village, sur le modèle du jeu des allumettes vu en cours. Côté PHP, il fallait au moins une classe Habitant et une classe Batiment.

Nous avons choisi de faire un jeu coopératif : les deux joueurs ne s'affrontent pas, ils sont dans la même équipe. Ils gagnent ou perdent ensemble.

Dans ce compte rendu, nous suivons le trajet d'une action : du clic d'un joueur jusqu'à la base de données, puis jusqu'à l'écran des deux joueurs. Par rapport à notre TP sur le club de boxe, c'est la grosse nouveauté : cette fois, deux navigateurs doivent rester d'accord en même temps.

02

Présentation du jeu

L'histoire : une tempête a ravagé Valmont. Les joueurs doivent reconstruire la Mairie avant l'hiver, qui arrive au jour 13.

Joueur 1

L'Intendante

Elle affecte les habitants aux bâtiments (gratuit) et accueille de nouveaux habitants (3 nourriture).

Joueur 2

Le Maître d'œuvre

Il construit les bâtiments (ferme, scierie, carrière, marché, Mairie) et répare ceux qui sont abîmés (1 bois).

Une journée : l'Intendante joue, puis le Maître d'œuvre, puis vient la nuit. Les bâtiments produisent, chaque habitant mange, et un événement peut arriver (tempête, loups ou marchand).

La fin : si la Mairie est construite, c'est gagné. Si l'hiver arrive, ou s'il n'y a plus d'habitants, c'est perdu. Pour les deux joueurs à la fois.

Joueur 1 L'écran de l'Intendante, au premier jour : c'est à elle de jouer.
Joueur 2 Au même moment, chez le Maître d'œuvre : ses boutons sont grisés, son coéquipier joue.

Partie 1

Les notions fondamentales

Tout ce qui fait fonctionner le jeu coopératif, et qui découle du cours et du jeu des allumettes. Sans ces notions, le jeu ne marche pas.

03

Architecture technique

Avant de rentrer dans le code, voici le trajet d'une action. Le joueur 2 clique : envoyer() envoie sa demande en AJAX à api.php. Le serveur vérifie la demande et fait agir le village, qui lit et écrit dans la base. Puis il répond en JSON.

  1. NavigateursJ1 et J2jeu.js
  2. L'arbitreapi.php3 vérifications
  3. Les classesVillageBatiment, Habitant
  4. La mémoireMySQL4 tables

Pendant ce temps, chaque navigateur envoie action=get toutes les secondes : c'est le polling.

(1) Le navigateur envoie l'action en AJAX. (2) api.php vérifie, puis fait agir le village. (3) Le village lit et écrit dans la base avec PDO. (4) Le serveur répond l'état complet en JSON.

Et le joueur 1 dans tout ça ? Il n'a rien fait, mais il redemande l'état toutes les secondes : il voit donc le changement presque tout de suite.

Le rôle de chaque fichier

FichierSon rôleDans les allumettes
sql/init.sqlCrée les 4 tables : la mémoire de la partieLa table du jeu
config.phpOuvre la connexion PDOLa connexion en haut du fichier
classes/Habitant.php
classes/Batiment.php
Décrivent un habitant, un bâtiment et ses 5 classes fillesNouveau : la POO
classes/Village.phpCharge le village, le fait agir, le sauvegardeNouveau
api.phpReçoit l'action, vérifie, répond en JSONallumettes.php
joueur1.html
joueur2.html
La structure des deux pages, avec #idjoueurJoueur1.html, Joueur2.html
js/jeu.jsPolling, envoi des actions, affichagescript.js
css/style.css
js/ambiance.js
img/illustrations.svg
L'apparence et le décor (voir la Partie 2)—
04

La base de données

Notre jeu se joue à deux, chacun sur son navigateur. Il nous fallait donc un endroit commun pour garder l'état de la partie. Une variable PHP ne suffit pas : PHP oublie tout à la fin de chaque requête. Nous avons donc utilisé une base MySQL. À chaque clic, notre PHP lit l'état du jeu, applique l'action, puis le réécrit.

Quatre tables

partie (à qui de jouer, quel jour, le statut), village (les ressources), batiment et habitant (une ligne par bâtiment et par habitant). La colonne joueur_actif joue le même rôle que dans le jeu des allumettes : c'est elle qui organise le tour par tour.

sql/init.sqlSQL
CREATE TABLE partie (
    id INT PRIMARY KEY,             -- toujours 1 : une seule partie à la fois
    joueur_actif INT NOT NULL,      -- à qui de jouer : 1 ou 2
    jour INT NOT NULL,
    statut VARCHAR(20) NOT NULL,    -- 'attente', 'en_cours', 'gagne' ou 'perdu'
    message TEXT                    -- dernier événement, affiché aux deux joueurs
);

CREATE TABLE village (
    id INT PRIMARY KEY,             -- toujours 1
    nourriture INT NOT NULL,
    bois INT NOT NULL,
    pierre INT NOT NULL,
    pieces INT NOT NULL
);

CREATE TABLE batiment (
    id INT AUTO_INCREMENT PRIMARY KEY,
    type VARCHAR(20) NOT NULL,      -- le nom de la classe PHP : 'Ferme'...
    endommage BOOLEAN NOT NULL DEFAULT 0
);

CREATE TABLE habitant (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nom VARCHAR(30) NOT NULL,
    batiment_id INT NULL            -- NULL = sans travail
);

La connexion : PDO

Pour parler à la base, PHP utilise . Nous avons mis la connexion dans une fonction getConnexion(), dans un fichier à part, config.php. Nos identifiants ne sont donc écrits qu'à un seul endroit : si nous changeons d'hébergement, nous ne modifions qu'une ligne.

config.phpPHP
function getConnexion() {
    // Identifiants masqués : la vraie valeur reste sur le serveur
    $conn = new PDO('mysql:host=localhost;dbname=MA_BASE;charset=utf8', 'ID', 'MOT_DE_PASSE');

    // Une requête SQL fausse affiche une erreur claire
    $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // Chaque ligne se lit avec le nom de la colonne : $ligne['nom']
    $conn->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

    return $conn;
}
Les identifiants sont remplacés par ID et MOT_DE_PASSE : ce fichier n'est jamais partagé.

Nous avons réglé deux options. La première affiche une erreur claire quand une requête SQL est fausse, au lieu de ne rien faire : très utile pour trouver nos bugs. La seconde permet de lire une ligne avec le nom de la colonne ($ligne['nom']), ce qui est plus lisible qu'un numéro. La fonction renvoie la connexion, que api.php donne ensuite à nos classes.

05

La programmation orientée objet

Dans le jeu des allumettes, tout tenait dans un seul fichier PHP. Chez nous, il y a des habitants, des bâtiments, un village… Nous avons donc rangé le code en . En gros, une classe est un moule, et un est une chose fabriquée avec ce moule.

La classe Habitant

Un habitant a un identifiant, un nom et le bâtiment où il travaille (null s'il n'a pas de travail). Nous avons mis ses attributs en protected pour qu'ils ne soient modifiés que par les méthodes de la classe, comme affecter().

classes/Habitant.phpPHP
class Habitant {

    protected $id;
    protected $nom;
    protected $batimentId;   // null = sans travail

    public function __construct($id, $nom, $batimentId = null) {
        $this->id = $id;
        $this->nom = $nom;
        $this->batimentId = $batimentId;
    }

    public function getId()         { return $this->id; }
    public function getNom()        { return $this->nom; }
    public function getBatimentId() { return $this->batimentId; }

    public function affecter($batimentId) {
        $this->batimentId = $batimentId;
    }

    // Ce que l'on envoie au JavaScript
    public function toArray() {
        return ['id' => $this->id, 'nom' => $this->nom, 'batiment_id' => $this->batimentId];
    }
}

La méthode toArray() sert à choisir exactement ce que nous envoyons au JavaScript. Et cette classe ne fait aucune requête SQL : c'est Village qui charge et sauvegarde les habitants. Chaque classe garde ainsi un seul rôle.

La classe Batiment et ses classes filles

Batiment est la classe mère de tous nos bâtiments. Elle contient toute la logique commune : le coût, la capacité, l'état (abîmé ou non) et la méthode produire(). Cette méthode renvoie ce que le bâtiment produit selon le nombre de travailleurs, et un tableau vide s'il est abîmé.

classes/Batiment.phpPHP
class Batiment {

    protected $id;
    protected $nom = 'Bâtiment';
    protected $ressource = null;   // 'nourriture', 'bois', 'pierre', 'pieces'
    protected $production = 0;     // par travailleur et par jour
    protected $capacite = 2;
    protected $cout = [];
    protected $endommage = false;

    public function __construct($id, $endommage = false) {
        $this->id = $id;
        $this->endommage = $endommage;
    }

    public function endommager() { $this->endommage = true; }
    public function reparer()    { $this->endommage = false; }

    public function produire($nbTravailleurs) {
        if ($this->ressource === null || $this->endommage) {
            return [];
        }
        return [$this->ressource => $nbTravailleurs * $this->production];
    }
}

Au lieu d'écrire 5 classes complètes, nous avons créé 5 classes filles (Ferme, Scierie, Carriere, Marche, Mairie) qui de Batiment et ne changent que leurs valeurs. Une ferme sait donc déjà produire sans qu'on réécrive la méthode. Ajouter un nouveau bâtiment ne prend que quelques lignes.

classes/Batiment.phpPHP
class Ferme extends Batiment {
    protected $nom = 'Ferme';
    protected $ressource = 'nourriture';
    protected $production = 3;
    protected $cout = ['bois' => 2];
}

class Mairie extends Batiment {
    protected $nom = 'Mairie';
    protected $capacite = 0;
    protected $cout = ['bois' => 6, 'pierre' => 6, 'pieces' => 4];

    // Redéfinition : la Mairie résiste aux tempêtes
    public function endommager() { }
}

creerBatiment() : la fabrique

Dans la base, la colonne type contient un simple texte, par exemple « Ferme ». Ce texte est exactement le nom de la classe PHP. La fonction creerBatiment() est une : on lui donne un nom, elle rend le bon objet. « Ferme » devient new Ferme, qui sait déjà produire grâce à l'héritage.

classes/Batiment.phpPHP
function creerBatiment($type, $id, $endommage = false) {
    switch ($type) {
        case 'Ferme':    return new Ferme($id, $endommage);
        case 'Scierie':  return new Scierie($id, $endommage);
        case 'Carriere': return new Carriere($id, $endommage);
        case 'Marche':   return new Marche($id, $endommage);
        case 'Mairie':   return new Mairie($id, $endommage);
        default:         return null;   // type inconnu : refusé
    }
}

Elle sert aussi de liste blanche. Si quelqu'un envoie un type inventé depuis la console (F12), elle renvoie null, et la construction est refusée.

La classe Village

Village fait le lien entre la base de données et nos objets. À chaque requête, il charge le village, applique l'action du joueur, puis sauvegarde le résultat. Ensuite, l'objet disparaît à la fin du script PHP.

Au début, charger() lit la base et transforme chaque ligne en objet : creerBatiment() fabrique les bâtiments, et chaque habitant devient un objet Habitant. Ici, query() suffit, car aucune valeur ne vient du joueur.

classes/Village.phpPHP
private function charger() {
    $stmt = $this->conn->query('SELECT nourriture, bois, pierre, pieces FROM village WHERE id = 1');
    $this->ressources = $stmt->fetch();

    $stmt = $this->conn->query('SELECT * FROM batiment ORDER BY id');
    foreach ($stmt->fetchAll() as $ligne) {
        $this->batiments[] = creerBatiment($ligne['type'], $ligne['id'], (bool) $ligne['endommage']);
    }

    $stmt = $this->conn->query('SELECT * FROM habitant ORDER BY id');
    foreach ($stmt->fetchAll() as $ligne) {
        $this->habitants[] = new Habitant($ligne['id'], $ligne['nom'], $ligne['batiment_id']);
    }
}

construire() suit toujours le même ordre : vérifier que le bâtiment existe, vérifier avec peutPayer() que le village a assez de ressources, payer, puis enregistrer le bâtiment dans la base. Cette fois, la valeur vient du navigateur : nous utilisons donc une (prepare() puis execute()). Si l'action est refusée, la méthode renvoie false et note la raison dans le message affiché au joueur.

classes/Village.phpPHP
public function construire($type) {
    $batiment = creerBatiment($type, null);
    if ($batiment === null) {
        $this->message = 'Bâtiment inconnu.';
        return false;
    }
    if (!$this->peutPayer($batiment->getCout())) {
        $this->message = 'Pas assez de ressources pour : ' . $batiment->getNom();
        return false;
    }
    $this->payer($batiment->getCout());

    $stmt = $this->conn->prepare('INSERT INTO batiment (type) VALUES (:t)');
    $stmt->execute(['t' => $type]);
    $this->batiments[] = creerBatiment($type, $this->conn->lastInsertId());
    $this->message = 'construit : ' . $batiment->getNom();
    return true;
}

finDeJournee() : le tour de « l'ennemi »

Mais qui joue contre nous dans un jeu coopératif ? Personne… sauf la nuit. Quand le joueur 2 termine son tour, la journée se termine, et c'est cette méthode qui joue le rôle de l'ennemi.

classes/Village.phpPHP
public function finDeJournee() {
    // a) Production
    $gains = ['nourriture' => 0, 'bois' => 0, 'pierre' => 0, 'pieces' => 0];
    foreach ($this->batiments as $b) {
        $production = $b->produire($this->nbTravailleurs($b->getId()));
        foreach ($production as $ressource => $quantite) {
            $gains[$ressource] += $quantite;
        }
    }
    // (les gains sont ajoutés aux ressources, puis on écrit le bilan dans $rapport)

    // b) Repas : 1 nourriture par habitant, sinon famine
    $this->ressources['nourriture'] -= count($this->habitants);
    if ($this->ressources['nourriture'] < 0) {
        $this->ressources['nourriture'] = 0;
        $parti = array_pop($this->habitants);
        $stmt = $this->conn->prepare('DELETE FROM habitant WHERE id = :id');
        $stmt->execute(['id' => $parti->getId()]);
    }

    // c) L'événement de la nuit : un dé à 6 faces
    $de = rand(1, 6);
    if ($de == 1 && count($this->batiments) > 0) {
        $cible = $this->batiments[rand(0, count($this->batiments) - 1)];
        $cible->endommager();          // la Mairie, elle, ne bouge pas
    } else if ($de == 2) {
        $this->ressources['nourriture'] = max(0, $this->ressources['nourriture'] - 2);
    } else if ($de == 3) {
        $this->ressources['pieces'] += 1;
    }
    return $rapport;
}
Extrait raccourci : le calcul du bilan ($rapport) est masqué.
  • La production : pour chaque bâtiment, on compte ses travailleurs avec nbTravailleurs(), on appelle produire() et on additionne tout dans $gains.
  • Le repas : chaque habitant mange 1 nourriture. S'il n'y en a pas assez, c'est la famine : le dernier habitant arrivé quitte le village (array_pop(), puis une requête préparée pour le supprimer de la base).
  • Le dé de la nuit : 1, une tempête abîme un bâtiment au hasard ; 2, des loups volent 2 nourriture ; 3, un marchand laisse 1 pièce ; 4, 5 ou 6, il ne se passe rien.

Le jeu garde ainsi une part de hasard, avec des mauvaises nouvelles, mais aussi des bonnes.

sauvegarder() et toArray()

sauvegarder() recopie l'état des objets dans les tables. Remarque : la requête des habitants est préparée une seule fois, puis exécutée pour chaque habitant.

classes/Village.phpPHP
public function sauvegarder() {
    $stmt = $this->conn->prepare('UPDATE village SET nourriture = :n, bois = :b, pierre = :p, pieces = :o WHERE id = 1');
    $stmt->execute([
        'n' => $this->ressources['nourriture'], 'b' => $this->ressources['bois'],
        'p' => $this->ressources['pierre'],     'o' => $this->ressources['pieces'],
    ]);

    // On prépare UNE fois, puis on exécute pour chaque habitant
    $stmt = $this->conn->prepare('UPDATE habitant SET batiment_id = :b WHERE id = :id');
    foreach ($this->habitants as $h) {
        $stmt->execute(['b' => $h->getBatimentId(), 'id' => $h->getId()]);
    }
}

toArray() transforme le village en un simple tableau. C'est ce tableau qu'api.php envoie au JavaScript en JSON. Le nombre de travailleurs n'est pas stocké : il est calculé à ce moment-là.

classes/Village.phpPHP
public function toArray() {
    $batiments = [];
    foreach ($this->batiments as $b) {
        $infos = $b->toArray();
        $infos['travailleurs'] = $this->nbTravailleurs($b->getId());   // calculé, pas stocké
        $batiments[] = $infos;
    }
    $habitants = [];
    foreach ($this->habitants as $h) {
        $habitants[] = $h->toArray();
    }
    return ['ressources' => $this->ressources, 'batiments' => $batiments, 'habitants' => $habitants];
}
06

Le serveur : api.php

api.php est notre allumettes.php. C'est le seul fichier PHP appelé par le JavaScript, et c'est l'arbitre : toutes les règles sont vérifiées ici. Nous l'expliquons dans l'ordre où il s'exécute.

Avant de jouer : tout mettre en place

Avant de traiter la moindre demande, api.php annonce le format de sa réponse et charge les autres fichiers.

api.phpPHP
header('Content-Type: application/json');

require 'config.php';
require 'classes/Habitant.php';
require 'classes/Batiment.php';
require 'classes/Village.php';

header() prévient le navigateur que la réponse sera du JSON : cette ligne doit être écrite avant tout affichage. require inclut la connexion et les trois classes. L'ordre compte : Village utilise Habitant et Batiment, il doit donc être chargé après eux.

Les deux réglages du jeu

api.phpPHP
define('JOUR_HIVER', 13);   // 15 = facile, 13 = normal, 11 = difficile

$ROLES = [
    1 => ['nom' => 'Intendante',     'actions' => ['affecter', 'recruter', 'terminer']],
    2 => ['nom' => "Maître d'œuvre", 'actions' => ['construire', 'reparer', 'terminer']],
];

JOUR_HIVER est une constante : une valeur fixe, écrite à un seul endroit. C'est le réglage de difficulté : pour rendre le jeu plus facile, il suffit de changer 13 en 15.

Lire la demande

Il faut maintenant savoir ce que le joueur demande, et qui il est.

api.phpPHP
$conn = getConnexion();
$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : 'get';
$joueur = isset($_REQUEST['joueur']) ? (int) $_REQUEST['joueur'] : 0;
$erreur = '';
  • getConnexion() ouvre la connexion à la base (elle est définie dans config.php).
  • contient les informations envoyées par le JavaScript, comme $_POST dans un formulaire classique.
  • vérifie qu'une information existe. Si l'action est absente, on considère que c'est une simple lecture (get), sans danger.
  • (int) transforme le numéro du joueur en nombre entier : « 2 » devient 2, et un texte bizarre devient 0, qui ne correspond à aucun joueur.
  • $erreur commence vide. Il ne se remplit que si une action est refusée.

Que faire de cette demande ?

Comme un aiguillage de train, cette partie envoie chaque demande sur la bonne voie. Il y a trois cas possibles.

api.phpPHP
if ($action == 'start') {
    reinitialiser($conn);              // nouvelle partie

} else if ($action != 'get') {
    // toute action de jeu : les trois vérifications, puis l'action
}
// 'get' : rien à faire, on passe directement à la réponse
Le squelette de l'aiguillage : le détail du deuxième cas est expliqué juste après.

start remet tout à zéro avec reinitialiser(), et les deux joueurs y ont accès. get ne fait rien de spécial : on passe directement à la réponse. Toute autre action doit passer trois vérifications.

Les trois vérifications

Avant d'exécuter une action de jeu, api.php pose trois questions, dans cet ordre. À la première réponse « non », il s'arrête et prépare un message d'erreur.

  1. 1

    La partie est-elle en cours ?

    $partie['statut'] != 'en_cours'

    Si non « La partie est terminée. »

  2. 2

    Est-ce bien son tour ?

    $partie['joueur_actif'] != $joueur

    Si non « Ce n'est pas votre tour. »

  3. 3

    L'action fait-elle partie de son rôle ?

    in_array($action, $ROLES[$joueur]['actions'])

    Si non « Cette action n'appartient pas à votre rôle. »

Oui aux trois questions : on charge le village et on applique l'action.

api.phpPHP
$partie = lirePartie($conn);

if ($partie['statut'] != 'en_cours') {
    $erreur = 'La partie est terminée. Cliquez sur "Nouvelle partie".';

} else if ($partie['joueur_actif'] != $joueur) {
    $erreur = "Ce n'est pas votre tour : votre coéquipier joue.";

} else if (!in_array($action, $ROLES[$joueur]['actions'])) {
    $erreur = "Cette action n'appartient pas à votre rôle.";

} else {
    $village = new Village($conn);
    // ... on applique l'action
}

Appliquer l'action

Les trois vérifications sont passées : on charge le village, puis on distingue deux sortes d'actions. D'abord, l'action gratuite : l'Intendante peut déplacer ses habitants autant qu'elle veut avant de jouer. On enregistre le changement avec sauvegarder(), mais le tour ne passe pas.

api.phpPHP
if ($action == 'affecter') {
    // Action gratuite : on enregistre, mais le tour ne passe PAS
    if ($village->affecter((int) $_REQUEST['habitant'], (int) $_REQUEST['batiment'])) {
        $village->sauvegarder();
    } else {
        $erreur = $village->getMessage();
    }
}

Ensuite, les actions qui terminent le tour : construire, recruter, réparer ou passer son tour. Chaque méthode de Village répond true (réussie) ou false (refusée), et la réponse est rangée dans $ok.

api.phpPHP
if ($action == 'construire') {
    $ok = $village->construire($_REQUEST['type']);
} else if ($action == 'recruter') {
    $ok = $village->recruter();
} else if ($action == 'reparer') {
    $ok = $village->reparer((int) $_REQUEST['batiment']);
} else {                // 'terminer' : rien à vérifier
    $ok = true;
}

if ($ok) {
    $nomRole = $ROLES[$joueur]['nom'];
    $texte = ($action == 'terminer') ? $nomRole . ' passe son tour.' : $nomRole . ' ' . $village->getMessage() . '.';
    passerLeTour($conn, $partie, $village, $joueur, $texte);
} else {
    $erreur = $village->getMessage();   // refusée : le joueur rejoue
}

Si l'action réussit, on prépare un message lisible par les deux joueurs, par exemple « Maître d'œuvre construit : Ferme. », puis on appelle passerLeTour(). Si elle est refusée (pas assez de bois, par exemple), on ne change rien : le joueur garde la main et voit l'explication.

Une seule réponse, toujours la même

Quelle que soit la demande (acceptée, refusée ou simple lecture), api.php termine toujours de la même façon.

api.phpPHP
echo json_encode(etatComplet($conn, $erreur));

function etatComplet($conn, $erreur) {
    $village = new Village($conn);   // rechargé : l'état est forcément à jour
    return [
        'partie' => lirePartie($conn),
        'village' => $village->toArray(),
        'jour_hiver' => JOUR_HIVER,
        'erreur' => $erreur,
    ];
}

etatComplet() rassemble tout ce que le JavaScript doit afficher. Le village est rechargé depuis la base à ce moment-là : la réponse est donc forcément à jour, même juste après une action. Puis json_encode() transforme ce tableau PHP en texte JSON, et echo l'envoie au navigateur. C'est le seul echo de tout le projet : un deuxième casserait le JSON.

Réponse reçue par le navigateurJSON
{
  "partie":  { "jour": 3, "joueur_actif": 1, "statut": "en_cours", "message": "Maître d'œuvre construit : Ferme. ..." },
  "village": { "ressources": { "nourriture": 5, "bois": 2, "pierre": 0, "pieces": 1 },
               "batiments": [ ... ], "habitants": [ ... ] },
  "jour_hiver": 13,
  "erreur": ""
}
Ce que reçoit le JavaScript (exemple simplifié).

passerLeTour() : faire avancer la partie

En bas du fichier, des petites fonctions font le travail précis. Voici la plus importante.

api.phpPHP
function passerLeTour($conn, $partie, $village, $joueur, $texte) {
    $statut = 'en_cours';
    $jour = $partie['jour'];
    $suivant = ($joueur == 1) ? 2 : 1;          // opérateur ternaire

    if ($joueur == 2) {                         // fin de la journée
        $texte .= ' ' . $village->finDeJournee();
        $jour++;
    }
    $village->sauvegarder();

    if ($village->aUneMairie()) {
        $statut = 'gagne';
    } else if ($village->nbHabitants() == 0 || $jour >= JOUR_HIVER) {
        $statut = 'perdu';
    }

    $stmt = $conn->prepare('UPDATE partie SET joueur_actif = :ja, jour = :j, statut = :s, message = :m WHERE id = 1');
    $stmt->execute(['ja' => $suivant, 'j' => $jour, 's' => $statut, 'm' => $texte]);
}
Extrait raccourci : les messages de victoire et de défaite sont masqués.
  1. Le joueur suivant : si c'était le joueur 1, c'est au 2, sinon c'est au 1. C'est l'opérateur , le même joueur == 1 ? 2 : 1 que dans le jeu des allumettes.
  2. La nuit : si le joueur 2 vient de jouer, la journée se termine. finDeJournee() gère la production, le repas et l'événement de la nuit, et le jour augmente de 1.
  3. La fin de partie : une Mairie construite, c'est gagné ; plus d'habitants ou l'hiver atteint, c'est perdu. Pour les deux joueurs à la fois.
  4. L'enregistrement : avec une requête préparée, car le message contient des apostrophes. On ne le colle jamais directement dans le SQL.

Lire et recommencer

api.phpPHP
function lirePartie($conn) {
    $stmt = $conn->query('SELECT * FROM partie WHERE id = 1');
    return $stmt->fetch();
}

function reinitialiser($conn) {
    $conn->exec('DELETE FROM batiment');
    $conn->exec('DELETE FROM habitant');
    $conn->exec('UPDATE village SET nourriture = 3, bois = 4, pierre = 0, pieces = 0 WHERE id = 1');
    $conn->exec("INSERT INTO batiment (type, endommage) VALUES ('Ferme', 0), ('Scierie', 0)");
    $conn->exec("INSERT INTO habitant (nom) VALUES ('Anselme'), ('Margot'), ('Côme')");
    $conn->exec("UPDATE partie SET joueur_actif = 1, jour = 1, statut = 'en_cours', message = '...' WHERE id = 1");
}
Le message de départ est raccourci.

lirePartie() lit l'unique ligne de la table partie : le jour, le joueur actif, le statut et le dernier message. reinitialiser() efface les bâtiments et les habitants, puis remet le village de départ : une ferme, une scierie, trois habitants, 3 nourriture et 4 bois. Ici, exec() suffit sans requête préparée, car aucune valeur ne vient du joueur.

07

Le client : HTML et JavaScript

Deux pages presque identiques

Nos deux pages de jeu, joueur1.html et joueur2.html, sont des copies. Une seule chose change : le chiffre dans <span id="idjoueur">. Notre JavaScript le lit pour savoir quel joueur il est, comme le #idjoueur du jeu des allumettes.

joueur1.htmlHTML
<h1>Joueur <span id="idjoueur">1</span>, <span id="mon-role"></span></h1>

<!-- Des zones vides, remplies par jeu.js -->
<ul id="ressources" class="reserves"></ul>
<div id="batiments" class="batiments"></div>
<ul id="habitants" class="habitants"></ul>

<!-- data-type : le nom exact de la classe PHP -->
<button class="btn-construire" data-type="Ferme">Construire une ferme (2 bois)</button>

<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="js/jeu.js" defer></script>
Extraits de joueur1.html.

Le HTML ne contient que la structure : les zones comme #ressources ou #batiments sont vides au départ, et c'est le JavaScript qui les remplit avec les données reçues. Pour les boutons de construction, nous avons ajouté un attribut data-type : le JavaScript le lit, donc un seul code gère les 5 boutons.

envoyer() : une seule fonction pour parler au serveur

Pour communiquer avec le serveur sans recharger la page, nous utilisons AJAX avec jQuery. Le code du professeur répétait le même $.ajax à plusieurs endroits ; nous l'avons regroupé dans une seule fonction, envoyer(), réutilisée partout.

js/jeu.jsJavaScript
const MON_NUMERO = $('#idjoueur').text();   // '1' ou '2'

function envoyer(donnees) {
    donnees.joueur = MON_NUMERO;     // on dit toujours au serveur qui parle
    $.ajax({
        url: 'api.php',
        type: 'POST',
        dataType: 'json',            // jQuery transforme la réponse en objet JS
        data: donnees,               // devient $_REQUEST['action'], $_REQUEST['joueur']...
        success: afficher,
        error: function () { $('#erreur').text('Serveur injoignable.'); }
    });
}

Elle envoie l'action du joueur à api.php et attend du JSON en réponse, que jQuery transforme directement en objet JavaScript. Quand la réponse arrive, elle appelle afficher(). Et nous ajoutons toujours le numéro du joueur, pour que le serveur puisse vérifier que c'est bien son tour.

Le polling : rafraîchir toutes les secondes

Le serveur ne peut pas prévenir un navigateur tout seul. Chaque page demande donc l'état du jeu toutes les secondes avec . C'est comme ça que le joueur 2 voit le coup du joueur 1 en moins d'une seconde.

js/jeu.jsJavaScript
$(function () {
    $('#mon-role').text(ROLES[MON_NUMERO]);
    $('.role').hide();
    $('#role-' + MON_NUMERO).show();     // chaque joueur ne voit que son panneau

    rafraichir();                        // tout de suite...
    setInterval(rafraichir, 1000);       // ...puis toutes les secondes
});

function rafraichir() {
    envoyer({ action: 'get' });
}

afficher() : la seule fonction qui modifie l'écran

La réponse arrive toujours ici, qu'elle vienne d'un clic ou du polling.

js/jeu.jsJavaScript
function afficher(data) {
    if (data.erreur) {
        $('#erreur').text(data.erreur);
        setTimeout(function () { $('#erreur').text(''); }, 3000);
    }

    // Rien n'a changé depuis la dernière seconde ? On ne touche à rien
    const etat = JSON.stringify([data.partie, data.village]);
    if (etat == dernierEtat) return;
    dernierEtat = etat;

    const partie = data.partie;
    const village = data.village;
    const monTour = (partie.statut == 'en_cours' && partie.joueur_actif == MON_NUMERO);

    afficherStatut(partie, monTour);
    afficherReserves(village, precedent);
    afficherBatiments(village, monTour, precedent);
    afficherHabitants(village, monTour);

    // Confort : la vraie protection reste dans api.php
    $('#actions button').prop('disabled', !monTour);
}
Le cœur de la fonction. Les petites fonctions d'affichage (réserves, bâtiments…) remplissent chacune leur zone.
  1. S'il y a une erreur (action refusée par le serveur), on l'affiche pendant 3 secondes.
  2. On compare avec l'état précédent : JSON.stringify() transforme l'état en texte. Si ce texte est le même que dernierEtat, rien n'a changé, et on ne redessine pas la page pour rien.
  3. monTour est vrai si la partie est en cours ET que joueur_actif est mon numéro.
  4. On remplit chaque zone de la page, puis on grise les boutons si ce n'est pas mon tour.

Réagir aux clics

Pour les boutons de construction, un simple .click() suffit : $(this) désigne le bouton cliqué, et nous lisons son data-type pour savoir quel bâtiment construire.

js/jeu.jsJavaScript
// Un seul code pour les 5 boutons de construction
$('.btn-construire').click(function () {
    envoyer({ action: 'construire', type: $(this).data('type') });
});

// Délégation : les <select> sont créés plus tard par afficher()
$('#habitants').on('change', 'select', function () {
    envoyer({ action: 'affecter', habitant: $(this).data('habitant'), batiment: $(this).val() });
});
$('#batiments').on('click', '.btn-reparer', function () {
    envoyer({ action: 'reparer', batiment: $(this).data('batiment') });
});

Pour les menus des habitants, c'est différent : ils sont créés par le JavaScript après le chargement de la page. Résultat, $('select').change() ne les voyait pas, et rien ne se passait. Nous avons donc utilisé la avec .on() : au lieu d'écouter les menus eux-mêmes, nous écoutons leur parent #habitants, qui existe toujours. Même chose pour les boutons « Réparer ».

08

Le tour de jeu de A à Z

Pour finir la Partie 1, nous mettons tout bout à bout. Exemple : le Maître d'œuvre (joueur 2) clique sur « Construire une ferme ».

  1. Navigateur du joueur 2

    Le clic

    Le bouton porte data-type="Ferme". Le .click() appelle envoyer({ action: 'construire', type: 'Ferme' }), qui ajoute joueur = 2 et part en AJAX vers api.php.

  2. api.php

    Les trois vérifications

    La partie est en cours, c'est bien le tour du joueur 2, et construire fait partie de $ROLES[2]. Tout est bon.

  3. Village

    La construction

    construire('Ferme') fabrique la ferme avec creerBatiment(), vérifie qu'il y a assez de bois avec peutPayer(), paie, puis l'enregistre avec une requête préparée.

  4. api.php

    La fin de la journée

    C'est le joueur 2 qui vient de jouer : passerLeTour() appelle finDeJournee() (production, repas, dé de la nuit). Le jour augmente, la victoire et la défaite sont testées, et joueur_actif repasse à 1.

  5. Réponse

    Le JSON

    etatComplet() et json_encode() renvoient tout l'état. Chez le joueur 2, afficher() met l'écran à jour et ses boutons se grisent.

  6. Navigateur du joueur 1

    Le polling

    Moins d'une seconde plus tard, son action=get récupère le nouvel état : l'Intendante voit la nouvelle ferme, le bilan de la nuit et « À vous de jouer ».

Partie 2

Les notions complémentaires

Ce que nous avons ajouté pour notre portfolio. Sans ces notions, le jeu marche toujours : il est juste moins beau et moins agréable. À lire en option.

09

L'identité visuelle

Notre direction artistique s'appelle « la dernière veillée avant l'hiver ». Le jeu se passe la nuit, à la lueur des torches. Nous voulions qu'il ressemble à un jeu indépendant récent plutôt qu'à un « thème médiéval » : beaucoup de noir, de grands espaces, peu d'éléments mais bien grands.

Toutes les couleurs sont des , définies une seule fois dans :root. Chaque couleur a un sens : l'or de torche pour ce qui concerne le joueur (son tour, son action principale, le focus), le bleu givre pour l'hiver qui menace. Changer une couleur à cet endroit la change dans tout le site.

css/style.cssCSS
:root {
    --suie: #0f0d0c;            /* fond de page : la nuit */
    --charbon: #1b1815;         /* fond des panneaux */
    --parchemin: #ecdfc4;       /* texte principal (14,7:1 sur suie) */
    --or-torche: #e3a849;       /* actions du joueur, focus, « à vous » */
    --givre: #a9d3ea;           /* l'hiver : neige, compte à rebours */
    --sang: #e0604f;            /* danger, défaite */

    --police-gothique: "Grenze Gotisch", "Cinzel", Georgia, serif;
    --police-gravee: "Cinzel", Georgia, serif;
    --police-texte: "Alegreya", Georgia, serif;
}

.batiment.Mairie { border-color: var(--or-torche); }
Une partie des variables de style.css.

Trois polices, trois rôles : Grenze Gotisch uniquement pour le mot « Valmont » et les écrans de fin, Cinzel pour les boutons et les titres de panneaux, Alegreya pour tout le reste, chiffres compris.

Les illustrations SVG

Les ressources, les bâtiments et les blasons sont des dessins originaux en , tous rangés dans un seul fichier, img/illustrations.svg. Chaque dessin est un <symbol> avec un id, que l'on réutilise partout avec <use>. Comme le dessin prend la couleur du texte (currentColor), le CSS peut le colorer ou le noircir quand un bâtiment est abîmé.

js/jeu.jsJavaScript
// Un seul fichier img/illustrations.svg contient tous les dessins (<symbol id="ferme">...)
function illustration(nom, classe) {
    return '<svg class="' + classe + '" aria-hidden="true" focusable="false">'
         + '<use href="img/illustrations.svg#' + nom + '"></use></svg>';
}
10

L'ambiance : la neige qui monte

La neige n'est pas qu'un décor : elle donne une information de jeu. Plus l'hiver approche, plus il neige, et plus le vent souffle. Au dernier jour, c'est presque une tempête.

js/jeu.js · js/ambiance.jsJavaScript
// jeu.js : 0 au jour 1, 1 (tempête) au dernier jour avant l'hiver
let neige = (partie.jour - 1) / (jourHiver - 2);
if (partie.statut == 'perdu') neige = 1;
if (window.reglerNeige) reglerNeige(neige);

// ambiance.js : le nombre de flocons suit l'intensité
function ajusterFlocons() {
    const voulu = Math.round(NEIGE_MAX * (0.08 + 0.92 * intensite));
    while (flocons.length < voulu) flocons.push(nouveauFlocon(true));
    flocons.length = Math.min(flocons.length, voulu);
}

La neige est dessinée dans un placé derrière les panneaux : 150 flocons en HTML, ce serait 150 éléments à déplacer, beaucoup trop lourd. Sur l'accueil, ambiance.js ajoute quelques braises qui montent, et une parallaxe : les plans du village bougent légèrement avec la souris.

11

N'animer que ce qui change

Avec le polling, la page est redessinée à chaque changement d'état. Problème : si les animations étaient simplement écrites dans le CSS, elles se rejoueraient à chaque fois. Nous gardons donc en mémoire l'état précédent (precedent, rempli par memoriser()) et nous comparons. Seul ce qui a vraiment changé reçoit une classe d'animation.

js/jeu.jsJavaScript
if (avant) {
    const etaitLa = (b.id in avant.batiments);
    if (!etaitLa) classes += ' apparait';                                   // il vient d'être construit
    else if (b.endommage && !avant.batiments[b.id]) classes += ' vient-abime'; // la tempête
    else if (!b.endommage && avant.batiments[b.id]) classes += ' repare';
}

Un bâtiment neuf « s'élève » depuis le sol, un bâtiment touché par la tempête tremble et noircit, avec un bref éclat sur tout l'écran, et un bâtiment réparé a une petite lueur verte. Les chiffres des réserves défilent jusqu'à leur nouvelle valeur, avec un « +3 » ou un « −2 » qui monte et s'efface.

Les écrans de fin et la fenêtre de confirmation

Quand la partie se termine, une section cachée (hidden) devient un écran plein, comme la fin d'un jeu vidéo : le titre se « grave », puis le texte et le bouton arrivent l'un après l'autre. Lumière dorée en cas de victoire, voile froid et tempête de neige en cas de défaite.

Le bouton « Nouvelle partie » efface la partie pour les deux joueurs : nous demandons donc une confirmation avec la balise <dialog>. Elle est native : showModal() l'ouvre, la touche Échap la ferme, et le focus clavier reste dedans.

js/jeu.jsJavaScript
const dialogue = document.getElementById('dialogue-nouvelle-partie');
$('.btn-nouvelle-partie').click(function () { dialogue.showModal(); });
$('#btn-annuler').click(function () { dialogue.close(); });
$('#btn-confirmer').click(function () {
    dialogue.close();
    envoyer({ action: 'start' });
});
12

Le HUD : bougies et chantier

L'écran de jeu reprend les codes d'un HUD de jeu vidéo : en haut, le bandeau d'état avec le jour en grand ; au centre, le village ; à droite, le panneau d'actions du rôle, qui reste visible quand on fait défiler.

Le calendrier des bougies : une bougie par jour jusqu'à l'hiver. Les jours passés sont des bougies consumées, le jour en cours est la seule bougie allumée, et un flocon marque l'hiver au bout.

Le chantier de la Mairie : trois jauges comparent ce que le village possède au coût de la Mairie. C'est uniquement de l'affichage : c'est toujours le serveur qui décide si la construction est possible.

js/jeu.jsJavaScript
const COUT_MAIRIE = { bois: 6, pierre: 6, pieces: 4 };   // affichage seulement

for (let cle in COUT_MAIRIE) {
    const besoin = COUT_MAIRIE[cle];
    const possede = Math.min(r[cle], besoin);
    const pourcentage = Math.round(possede / besoin * 100);
    html += '<li class="' + (possede >= besoin ? 'complet' : '') + '">'
          + '<span class="jauge"><span style="width:' + pourcentage + '%"></span></span>'
          + '<span class="compte">' + r[cle] + ' / ' + besoin + '</span></li>';
}
13

La carte du village

Sous la grille du jeu, une carte en SVG montre le village de Valmont vu de haut. Chaque bâtiment construit apparaît sur le plan avec une petite animation, et la Mairie se reconstruit morceau par morceau à mesure que les matériaux arrivent. Un petit message, « Nouvelle ferme sur le plan ↓ », invite à descendre la voir.

14

L'accessibilité et les pages du site

Nous avons visé le niveau AA des règles d'accessibilité (WCAG). Le contraste de chaque couleur de texte est vérifié et noté dans style.css, le focus clavier est visible (un contour doré), et les boutons font au moins 44 pixels de haut. Aucune information n'est donnée par la couleur seule : la pastille d'état change aussi de texte et de forme.

joueur1.html · css/style.cssHTML
<!-- Annoncé par le lecteur d'écran quand le texte change -->
<p id="statut" class="pastille" aria-live="polite"></p>
<p id="erreur" class="alerte" role="alert"></p>

<style>
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>

Les zones qui changent pendant la partie ont un attribut aria-live : un lecteur d'écran annonce le nouveau message sans que le joueur ait à le chercher. Les erreurs sont en role="alert", donc annoncées tout de suite.

Les pages du site

Autour du jeu, le site compte trois autres pages qui partagent le même menu et la même feuille de style : l'accueil, avec le village en silhouette, les règles et le choix du rôle ; la page des créateurs ; et cette page, qui contient le support écrit.

15

Les difficultés rencontrées

Le duel devenu coopératif

Le problème

Notre première idée faisait s'affronter les deux joueurs, mais le sujet se prêtait mieux à un village commun.

La solution

Nous avons tout repensé autour de deux rôles complémentaires et du tableau $ROLES : chaque joueur a ses actions, et personne ne peut gagner seul.

Un jeu impossible à gagner

Le problème

Dans nos premières parties, nous perdions presque à chaque fois, même en jouant bien.

La solution

Nous avons ajusté les chiffres (production de la ferme, ressources de départ, jour de l'hiver) en jouant de nombreuses parties, puis regroupé le réglage dans la constante JOUR_HIVER.

Les menus qui ne répondaient pas

Le problème

Les menus des habitants sont créés après le chargement de la page : $('select').change() ne les voyait pas.

La solution

Nous avons utilisé la délégation d'événements avec .on(), sur le parent #habitants.

Le menu qui se refermait tout seul

Le problème

Avec le polling, les menus étaient recréés toutes les secondes, et un menu ouvert se refermait sous la souris du joueur.

La solution

Dans afficher(), nous comparons l'état reçu avec dernierEtat grâce à JSON.stringify() : si rien n'a changé, on ne redessine rien.

Séparer l'essentiel du reste

Le problème

Le jeu contient beaucoup d'éléments visuels faits pour notre portfolio, et nous avions peur qu'ils noient les notions du cours.

La solution

Nous avons coupé ce support en deux parties, pour que les notions fondamentales passent en premier.

16

L'utilisation de l'IA

Nous avons utilisé l'IA comme une aide pendant ce projet, surtout pour la partie visuelle. Nous voulions un jeu agréable à regarder, avec une identité cohérente. L'IA nous a aidés à construire style.css : les couleurs définies une seule fois avec des variables (--suie, --or-torche, --givre…), la mise en page du HUD en grille, et l'adaptation aux tablettes et aux téléphones. Elle nous a aussi aidés sur la carte du village, la neige et les animations, et elle nous a proposé des réglages d'accessibilité, comme le contour visible au clavier (:focus-visible).

Nous ne l'avons pas utilisée sans réfléchir. Nous avons testé le rendu sur plusieurs tailles d'écran, vérifié les contrastes, et modifié les couleurs et les espacements pour que le résultat nous ressemble. Surtout, nous avons relu le code pour comprendre ce que fait chaque règle, parce que nous devons pouvoir l'expliquer.

17

Conclusion

Ce projet nous a obligés à faire travailler ensemble des langages que nous avions surtout vus séparément : HTML, JavaScript, PHP orienté objet et MySQL. Nous avons compris comment un navigateur et un serveur échangent avec AJAX, pourquoi il faut une base de données pour garder l'état d'une partie, et pourquoi les règles du jeu doivent être vérifiées côté serveur, et pas seulement dans le JavaScript.

Nous sommes contents du résultat : notre jeu coopératif se joue à deux, chacun sur son navigateur, avec des rôles complémentaires et un hiver qui oblige à bien se coordonner. Les classes Habitant, Batiment et Village rendent le code facile à faire évoluer : ajouter un bâtiment ne prend que quelques lignes.

Avec plus de temps, nous aimerions ajouter un chat entre les deux joueurs, des habitants spécialisés (comme un bûcheron qui produit plus de bois, grâce à l'héritage), et la possibilité de lancer plusieurs parties en même temps.

Revenir en haut