Aller au contenu

Boucle de jeu & temps

Le serveur n'a pas de compteur de ticks global ni de boucle cadencée à intervalle fixe. Tout le temps du jeu est géré par un unique composant, le Scheduler, une file d'événements datés. Cette page explique comment le temps est mesuré, comment le paramètre -f (fréquence) influe sur la vitesse, et pourquoi cette conception évite toute attente active.

Le Scheduler : un tas d'événements datés

Le Scheduler est un min-heap (std::priority_queue ordonnée par échéance croissante) d'événements. Chaque événement associe une échéance absolue (steady_clock) à un callback.

// scheduler/Event.hpp:16-21
struct Event {
    std::chrono::steady_clock::time_point deadline;
    std::function<void()>                 callback;

    bool operator>(const Event& o) const { return deadline > o.deadline; }
};
// scheduler/Scheduler.hpp:32-35 (extrait)
int _freq;
std::priority_queue<Event, std::vector<Event>, std::greater<Event>> _queue;

L'usage de std::greater fait du sommet de la file l'événement le plus proche dans le temps — exactement ce qu'on veut consulter en permanence.

Planifier une action : schedule(cb, delayTicks)

Une action différée (l'achèvement d'une commande après son délai, la mort par faim, le respawn des ressources) est inscrite via schedule. Le délai est exprimé en unités de temps (ticks) ; le Scheduler les convertit en millisecondes en fonction de la fréquence.

// scheduler/Scheduler.cpp:9-18
void Scheduler::schedule(std::function<void()> cb, int delayTicks)
{
    Event event;

    event.deadline = std::chrono::steady_clock::now()
        + std::chrono::milliseconds((delayTicks * 1000) / _freq);
    event.callback = cb;

    _queue.push(event);
}

La conversion est (delayTicks * 1000) / freq millisecondes. Autrement dit :

$$ \text{1 unité de temps} = \frac{1000}{freq}\ \text{ms} $$

Effet de la fréquence

Avec -f 100 (valeur par défaut), une unité de temps dure 1000 / 100 = 10 ms. Une commande Forward (délai 7) aboutit donc après 70 ms.

Avec -f 1000, une unité ne dure plus que 1 ms, et la même Forward aboutit après 7 ms. Plus -f est élevé, plus le jeu va vite : la fréquence est un facteur d'accélération du temps de jeu. Pour la liste des délais par commande, voir Commandes & délais.

Exécuter les actions échues : tick()

À chaque réveil de poll(), la boucle principale appelle Scheduler::tick(), qui dépile et exécute tous les événements dont l'échéance est dépassée :

// scheduler/Scheduler.cpp:20-30
void Scheduler::tick()
{
    auto now = std::chrono::steady_clock::now();

    while (!_queue.empty() && _queue.top().deadline <= now) {
        Event event = _queue.top();
        _queue.pop();

        event.callback();
    }
}

Calculer le timeout de poll() : nextDeadlineMs()

C'est la clé de la conception. Plutôt que de scruter en boucle, le serveur demande au Scheduler combien de temps il peut dormir avant que quelque chose doive se produire, et passe cette valeur en timeout à poll().

// scheduler/Scheduler.cpp:32-44
int Scheduler::nextDeadlineMs() const
{
    auto now = std::chrono::steady_clock::now();

    if (_queue.empty())
        return -1;          // aucun event → poll() bloque indéfiniment

    auto diff = std::chrono::duration_cast<std::chrono::milliseconds>(
        _queue.top().deadline - now
    ).count();

    return std::max(0, static_cast<int>(diff));
}
  • File vide → -1 : poll() bloque indéfiniment jusqu'à activité réseau (le serveur dort vraiment, 0 % CPU).
  • Sinon → le nombre de millisecondes jusqu'au prochain événement (jamais négatif).

Une itération complète

sequenceDiagram
    participant Loop as Boucle principale
    participant Sched as Scheduler
    participant Poll as poll()
    participant Disp as AICommandDispatcher

    Loop->>Loop: updatePollEvents()
    Loop->>Sched: nextDeadlineMs()
    Sched-->>Loop: timeout (ms)
    Loop->>Poll: poll(_pollFds, timeout)
    Poll-->>Loop: fds prêts (ou expiration)
    Loop->>Loop: readClient / writeClient / accept
    Loop->>Sched: tick()  (exécute les events échus)
    Note over Sched: commandes terminées,<br/>faim, respawn…
    Loop->>Disp: pour chaque IA libre, popNextCommand()
    Loop->>Sched: schedule(réponse, cmd->getDelay())
    Note over Loop: retour en haut de boucle

Concrètement, la boucle (Server.cpp:111-...) :

  1. recalcule les événements poll (POLLIN toujours, POLLOUT à la demande) ;
  2. récupère le timeout via nextDeadlineMs() et appelle poll() ;
  3. traite les descripteurs prêts (accept, lecture, écriture) ;
  4. appelle scheduler->tick() pour exécuter les actions arrivées à échéance ;
  5. pour chaque client IA libre (ni occupé, ni en incantation), dépile une commande et planifie sa réalisation après cmd->getDelay() ticks via schedule.
// core/Server.cpp:114-115 et 161
int timeout = _scheduler->nextDeadlineMs();
int pollResult = poll(_pollFds.data(), _pollFds.size(), timeout);
...
_scheduler->tick();

Pourquoi cette conception évite l'attente active

Aucune CPU gaspillée

Une boucle de jeu naïve scruterait le temps en permanence (while (true) { if (now >= deadline) … }), brûlant du CPU pour rien. Ici, le serveur dort dans poll() exactement jusqu'à la prochaine échéance — ou indéfiniment s'il n'y a aucune échéance. Il ne se réveille que pour deux raisons : une activité réseau, ou une action de jeu à exécuter. Le résultat est un serveur à la fois économe (0 % CPU au repos) et précis à la milliseconde.

Notez l'absence de compteur de ticks global : le « temps » du jeu n'existe que sous la forme d'échéances absolues stockées dans le tas. C'est purement piloté par les échéances, pas par un cadencement.

Changer la fréquence à la volée : setFrequency

Le client graphique peut modifier la vitesse du jeu en cours de partie via la commande sst (voir Protocole). Le serveur applique alors Scheduler::setFrequency, qui réajuste toutes les échéances en cours pour préserver la durée restante exprimée en ticks :

// scheduler/Scheduler.cpp:46-79 (extrait)
void Scheduler::setFrequency(int freq)
{
    ...
    while (!_queue.empty()) {
        Event event = _queue.top();
        _queue.pop();
        // temps restant converti en ticks à l'ancienne fréquence...
        int remainingTicks = static_cast<int>((remainingMs * _freq) / 1000);
        // ...puis ré-exprimé en ms à la nouvelle fréquence
        event.deadline = now +
            std::chrono::milliseconds((remainingTicks * 1000) / freq);
        newQueue.push(event);
    }
    _freq = freq;
    _queue = newQueue;
}

Cohérence du jeu

Sans ce ré-échelonnage, accélérer le jeu ne modifierait que les nouvelles actions ; celles déjà planifiées garderaient leur ancienne durée en millisecondes. En reconvertissant ticks → ms à la nouvelle fréquence, une commande qu'il restait par exemple « 5 ticks » à attendre attendra bien 5 × (1000/freq) ms après le changement.

Pour le détail des délais associés à chaque commande, voir Commandes & délais.