Implémentation d'un jeu d'échecs chinois en Qt : Architectures et algorithmes

Fonctionnalités couvertes

  1. Mode deux joueurs (local)

  2. Mode contre l'ordinateur

  3. Mode réseau

  4. Fondations : mode deux joueurs


1.1 Rendu du plateau et des pièces

Le plateau se compose de 10 rangées et 9 colonnes, avec la rivière séparant les deux camps. Les palais impériaux (9 cases centrales en bas et en haut) ainsi que les positions des soldats et des canons sont représentés. Les 32 pièces sont dessinées comme des cercles contenant un caractère chinois via drawStone(painter, i).

Règles de dessin :

  • Les pièces mortes ne sont pas affichées : if (_s[id]._dead) return;
  • Lignes : painter.setPen(Qt::black);
  • Remplissage : painter.setBrush(QBrush(Qt::gray));
  • Police : painter.setFont(QFont("system", _r, 700));
  • Texte centré : painter.drawText(rect, _s[id].getText(), QTextOption(Qt::AlignCenter)); — la méthode getText() utilise un switch pour retourner le QString approprié.

1.2 Initialisation du plateau

Chaque pièce possède les attributs : _id, _row, _col, _type, _red, _dead. Les pièces sont stockées dans un tableau _s[32] : les rouges occupent les indices 0 à 15 (en partant du coin supérieur gauche), les noires les indices 16 à 31 (coin inférieur droit), exploitant la symétrie centrale.

Propriété clé : Pour deux pièces symétriques, la somme des coordonnées est constante : row(rouge) + row(noir) = 9 ; col(rouge) + col(noir) = 8.

Les types sont définis par une énumération :

enum TYPE { CHE, MA, XIANG, SHI, JIANG, PAO, BING } _type;

Les positions initiales des pièces rouges sont définies par un tableau de structures. Les positions noires sont déduites par symétrie.

1.3 Règles de déplacement

La fonction canMove(int moveId, int killedId, int row, int col) utilise un switch(_s[moveId]._type) pour appeler la vérification propre à chaque pièce.

  • Char : se déplace en ligne droite ; vérifier qu'aucune pièce ne se trouve entre les points de départ et d'arrivée.
  • Cheval : mouvemetn en L ; vérifier la case de blocage (le "sabot").
  • Éléphant : mouvement en diagonale sur 2 cases ; vérifier la case centrale (l'œil) et rester dans son propre camp.
  • Conseiller : se déplace en diagonale dans le palais ; les règles diffèrent entre le haut et le bas.
  • Général : se déplace orthogonalement dans le palais. Règle du "général volant" : si la pièce ciblée est un général adverse et qu'aucune pièce ne les sépare, le mouvement est autorisé.
  • Canon : se déplace en ligne droite. Pour capturer, exactement une pièce doit se trouver entre les positions ; pour un simple déplacement, aucune pièce entre les deux.
  • Soldat : ne peut reculer. Avance uniquement ; après avoir traversé la rivière, peut se déplacer latéralement. Les règles diffèrent entre le haut et le bas.

Fonctions auxiliaires :

Calcul de la relation entre positions (somme pondérée) :

int relation(int r1, int c1, int r, int c) {
    return qAbs(c1 - c) + qAbs(10 * (r1 - r));
}

Comptage du nombre de pièces entre deux points :

int getStoneCountAtLine(int r1, int c1, int r2, int c2) {
    int count = 0;
    if (r1 != r2 && c1 == c2) {
        int min = qMin(r1, r2);
        int max = qMax(r1, r2);
        for (int r = min + 1; r < max; ++r)
            if (getStoneId(r, c1) != -1) ++count;
    } else if (r1 == r2 && c1 != c2) {
        int min = qMin(c1, c2);
        int max = qMax(c1, c2);
        for (int c = min + 1; c < max; ++c)
            if (getStoneId(r1, c) != -1) ++count;
    } else {
        return -1;
    }
    return count;
}

Vérification du camp pour les pièces du bas :

bool isBottomSide(int id) {
    return _bRedSide == _s[id]._red;
}

1.4 Mécanisme de déplaceemnt

L'événement mouseReleaseEvent(QMouseEvent *ev) convertit les coordonnées pixel en coordonnées de plateau via getClickRowCol(), puis obtient l'identifiant de la pièce avec getStoneId(row, col). La fonction virtuelle click(int id, int row, int col) permet la polymorphie entre les modes de jeu.

void Board::click(int id, int row, int col) {
    if (_selectId == -1) {
        trySelectStone(id);
    } else {
        tryMove(id, row, col);
    }
}

Sélection :

void trySelect(int id) {
    if (canSelect(id)) {
        _selectId = id;
        update();
    }
}
bool canSelect(int id) {
    return _bRedTurn == _s[id]._red;
}

Exécution du mouvement :

void tryMove(int killId, int row, int col) {
    if (canMove(_selectId, killId, row, col)) {
        moveStone(_selectId, killId, row, col);
        _selectId = -1;
        update();
    }
}

Fonction de déplacement centralisée :

void Board::moveStone(int moveId, int killId, int row, int col) {
    saveStep(moveId, killId, row, col, _steps);
    killStone(killId);
    moveStone(moveId, row, col);
}
void Board::moveStone(int moveId, int row, int col) {
    _s[moveId]._row = row;
    _s[moveId]._col = col;
    _bRedTurn = !_bRedTurn;
}
void Board::killStone(int id) {
    if (id == -1) return;
    _s[id]._dead = true;
}
void Board::saveStep(int moveId, int killId, int row, int col, QVector<Step*>& steps) {
    int row1, col1;
    getRowCol(moveId, row1, col1);
    Step* step = new Step;
    step->_colFrom = col1;
    step->_colTo = col;
    step->_rowFrom = row1;
    step->_rowTo = row;
    step->_moveId = moveId;
    step->_killId = killId;
    steps.append(step);
}

Fonctions de conversion :

  • Coordonnées de plateau vers pixels : point.rx() = (col + 1) * _r * 2
  • Pixels vers coordonnées de plateau :
bool Board::getClickRowCol(QPoint pt, int &row, int &col) {
    col = static_cast<int>(pt.x() / (d * 1.0) - 0.5);
    row = static_cast<int>(pt.y() / (d * 1.0) - 0.5);
    return (row >= 0 && row <= 9 && col >= 0 && col <= 8);
}

  1. Mode contre l'ordinateur : algorithme Minimax avec élagage alpha-bêta

L'ordinateur utilise une version surchargée de click() pour évaluer et exécuter le meilleur coup. Un minuteur de 100 ms évite le blocage de l'interface pendant le calcul.

void SingleGame::computerMove() {
    Step* step = getBestMove();
    moveStone(step->_moveId, step->_killId, step->_rowTo, step->_colTo);
    delete step;
    update();
}

Algorithme Minimax :

Step* SingleGame::getBestMove() {
    Step* result = nullptr;
    QVector<Step*> moves;
    getAllPossibleMoves(moves);
    int bestScore = -300000;

    while (!moves.isEmpty()) {
        Step* current = moves.takeLast();
        fakeMove(current);
        int score = getMinScore(this->_level - 1, bestScore);
        unfakeMove(current);

        if (score > bestScore) {
            if (result) delete result;
            result = current;
            bestScore = score;
        } else {
            delete current;
        }
    }
    return result;
}

Fonction de minimsiation avec élagage :

int SingleGame::getMinScore(int depth, int alpha) {
    if (depth == 0) return evaluate();

    QVector<Step*> moves;
    getAllPossibleMoves(moves);
    int minScore = 300000;

    while (!moves.isEmpty()) {
        Step* current = moves.takeLast();
        fakeMove(current);
        int score = getMaxScore(depth - 1, minScore);
        unfakeMove(current);
        delete current;

        if (score <= alpha) {
            qDeleteAll(moves);
            return score;
        }
        if (score < minScore) minScore = score;
    }
    return minScore;
}

Fonction de maximisation avec élagage :

int SingleGame::getMaxScore(int depth, int beta) {
    if (depth == 0) return evaluate();

    QVector<Step*> moves;
    getAllPossibleMoves(moves);
    int maxScore = -300000;

    while (!moves.isEmpty()) {
        Step* current = moves.takeLast();
        fakeMove(current);
        int score = getMinScore(depth - 1, maxScore);
        unfakeMove(current);
        delete current;

        if (score >= beta) {
            qDeleteAll(moves);
            return score;
        }
        if (score > maxScore) maxScore = score;
    }
    return maxScore;
}

Fonction d'évaluation : score() retourne la valeur totale des pièces noires moins celle des pièces rouges, chaque type ayant une valeur prédéfinie.

Génération de tous les coups possibles :

void SingleGame::getAllPossibleMoves(QVector<Step*>& moves) {
    int start = _bRedTurn ? 0 : 16;
    int end = _bRedTurn ? 16 : 32;

    for (int i = start; i < end; ++i) {
        if (_s[i]._dead) continue;
        for (int row = 0; row <= 9; ++row) {
            for (int col = 0; col <= 8; ++col) {
                int targetId = getStoneId(row, col);
                if (sameColor(i, targetId)) continue;
                if (canMove(i, targetId, row, col)) {
                    saveStep(i, targetId, row, col, moves);
                }
            }
        }
    }
}

Optimisation possible : Limiter la recherche par type de pièce plutôt que de parcourir toute la grille.

  1. Mode réseau

Client : connexion au serveur, envoi/réception de données via QTcpSocket.

Serveur : écoute sur un port, accepte une connexion et dialogue.

NetGame::NetGame(bool server, QWidget *parent) : Board(parent) {
    _server = nullptr;
    _socket = nullptr;
    _bServer = server;

    if (_bServer) {
        _server = new QTcpServer(this);
        _server->listen(QHostAddress::Any, 9899);
        connect(_server, &QTcpServer::newConnection, this, &NetGame::slotNewConnection);
    } else {
        _socket = new QTcpSocket(this);
        _socket->connectToHost(QHostAddress("127.0.0.1"), 9899);
        connect(_socket, &QTcpSocket::readyRead, this, &NetGame::slotDataArrive);
    }
}

Protocole de communication :

  • Flag 1 : Initialisation du plateau. Le serveur choisit le camp rouge aléatoirement et envoie.
  • Flag 2 : Déplacement d'une pièce. Les coordonnées sont transformées par symétrie centrale.
  • Flag 3 : Annulation du dernier coup (chaque camp annule son propre coup).

Réception côté serveur :

void NetGame::slotNewConnection() {
    if (_socket) return;
    _socket = _server->nextPendingConnection();
    connect(_socket, &QTcpSocket::readyRead, this, &NetGame::slotDataArrive);

    bool redSide = (qrand() % 2) > 0;
    init(redSide);

    QByteArray data;
    data.append(1);
    data.append(redSide ? 0 : 1);
    _socket->write(data);
}

Traitement des données reçues :

void NetGame::slotDataArrive() {
    QByteArray data = _socket->readAll();
    switch (data.at(0)) {
        case 1: initFromNetwork(data); break;
        case 2: clickFromNetwork(data); break;
        case 3: backFromNetwork(data); break;
        default: break;
    }
}

Fonctions de réponse aux flags :

void NetGame::backFromNetwork(QByteArray) {
    backOne();
    backOne();
}
void NetGame::clickFromNetwork(QByteArray data) {
    Board::click(data[1], 9 - data[2], 8 - data[3]);
}
void NetGame::initFromNetwork(QByteArray data) {
    bool redSide = data.at(1) > 0;
    init(redSide);
}

Envoi d'un coup :

void NetGame::click(int id, int row, int col) {
    if (_bRedTurn != _bSide) return;
    Board::click(id, row, col);
    QByteArray data;
    data.append(2);
    data.append(static_cast<char>(id));
    data.append(static_cast<char>(row));
    data.append(static_cast<char>(col));
    _socket->write(data);
}

Envoi d'une annulation :

void NetGame::back() {
    if (_bRedTurn != _bSide) return;
    backOne();
    backOne();
    QByteArray data;
    data.append(3);
    _socket->write(data);
}

Étiquettes: Qt C++ chinese-chess minimax alpha-beta-pruning

Publié le 14 juillet à 07h43