|
JustAnotherDnDGame 0.1.0
Jeu de rôle tactique au d20, vue de dessus, en C++/Qt
|
Cette page explique comment une touche physique devient un mouvement du personnage, et pourquoi ce trajet passe par une étape intermédiaire qui, au premier abord, peut sembler superflue.
Le gameplay ne dépend jamais directement d'une touche physique (EX-CTRL-010). Autrement dit, core::CharacterPhysicsSystem ne contient aucun test du genre « si la touche flèche gauche est enfoncée ». À la place, les entrées brutes (clavier, souris) sont d'abord traduites en une intention neutre — « aller à gauche », « sauter » — que Core consomme sans jamais savoir quelle touche a produit cette intention.
Pourquoi cette indirection. Sans elle, chaque système qui a besoin d'une entrée devrait connaître les touches physiques, ce qui pose plusieurs problèmes concrets :
Le trajet complet est donc : touche physique → hmi::InputState (état brut) → hmi::toPlayerInput (traduction) → core::PlayerInput (intention) → Core (logique de jeu). Les deux premières étapes vivent dans HMI (dépendantes de la plateforme), la dernière dans Core (indépendante).
Deux façons classiques d'observer les entrées existent :
Ce moteur choisit le second : hmi::InputState capture l'état clavier/souris une fois par frame (EX-CTRL-021), pas à chaque événement Qt individuel. Pourquoi : la logique de jeu tourne au pas de temps fixe (Boucle de jeu et pas de temps fixe) — elle a besoin d'un état d'entrée stable pendant toute la durée d'un pas, pas d'un flux d'événements arrivant de façon désynchronisée par rapport à ce rythme. Échantillonner une fois par frame donne cette photographie stable.
Savoir qu'une touche est enfoncée ne suffit pas : la physique a besoin de distinguer plusieurs questions liées mais différentes, par exemple pour le saut (Physique du personnage §3) :
InputState calcule ces trois fronts en gardant deux instantanés : l'état de la frame courante et celui de la frame précédente. Une touche est « pressée » (front montant) précisément quand elle est enfoncée maintenant mais ne l'était pas à la frame d'avant ; de même, « relâchée » (front descendant) quand elle ne l'est plus mais l'était juste avant. Sans conserver cet historique d'une frame, on ne pourrait connaître que l'état courant (keyDown), jamais le moment précis de transition (keyPressed/keyReleased) — pourtant essentiel au game feel du saut et du dash (des actions ponctuelles, à déclencher une seule fois par appui, pas à chaque frame où la touche reste enfoncée).
Remarque sur la fusion manette (détaillée plus bas) : keyPressed calcule un front par source puis les combine par OU logique — un bouton manette pressé alors que la touche clavier équivalente était déjà maintenue produit bien un nouveau front (celui de la manette), sans que le clavier « masque » cette pression. C'est cette même logique de combinaison en lecture, jamais en écriture, qui protège de tout effacement accidentel d'une touche (voir « La manette » ci-dessous).
Pourquoi avancer les fronts par pas et non par frame. Le rendu est découplé de la simulation (Boucle de jeu et pas de temps fixe) : sur un écran à 120/144 Hz, une partie des frames réelles n'exécute aucun pas fixe. Si l'état précédent était recopié à chaque frame de rendu (comme c'était le cas avant LOT-33), un appui capturé sur une de ces frames serait effacé avant qu'un pas ne l'ait lu — le front keyPressed disparaîtrait sans jamais déclencher l'action. En liant l'avancée de la ligne de base au pas consommé, un appui survit jusqu'à sa lecture, quel que soit le framerate : la latence entrée → action reste bornée à un pas (EX-CTRL-020) à 60 comme à 240 Hz. Le viewport échantillonne toujours l'état une fois par frame ; seule l'avancée des fronts change de cadence.
Perte de focus. À un Alt+Tab (ou tout basculement de fenêtre), le viewport ne reçoit pas d'événement de relâchement pour les touches maintenues. Sans précaution, une direction maintenue resterait « collée » et le personnage avancerait seul au retour. Le viewport traite donc QEvent::FocusOut en appelant InputState::releaseAll(), qui remet à zéro l'état courant et précédent de toutes les touches/boutons — sans produire de front « relâchée » parasite (courant == précédent == relâché).
InputState est délibérément indépendant de toute fenêtre (aucune dépendance <Windows.h> ni Qt dans son en-tête) : les événements peuvent être injectés directement en test, sans ouvrir de fenêtre, ce qui le rend testable en isolation (EX-NFR-010).
hmi::Key est une énumération dont les valeurs coïncident avec les codes virtuels Windows (VK_*) — un héritage pratique : pour les lettres, chiffres et l'espace, Qt::Key_A == 'A' == 0x41, si bien qu'aucune conversion n'est nécessaire. Seules les touches spéciales (flèches, Échap, Tab, Maj, Ctrl, F1/F2/F10) diffèrent entre l'énumération Qt et les codes VK : hmi::qtKeyToHmiKey (Source/HMI/Input/QtKeyMap) les convertit par une petite table, le reste passant directement. Le viewport y appelle cette fonction dans keyPressEvent/keyReleaseEvent avant de mettre à jour l'InputState.
hmi::toPlayerInput(input, gameKeyBindings, gamepadBindings) est la fonction qui seule connaît la correspondance entre touches/boutons et intentions ; elle produit un core::PlayerInput — le contrat de données entre HMI et Core :
Core ne reçoit que cette structure : il ignore totalement l'existence des touches/boutons physiques.
Deux sources indépendantes par action. hmi::GameAction (Gauche/Droite/Sauter/Dash/Viser haut/Viser bas) est l'action logique commune ; gameKeyBindings (hmi::GameKeyBindings) l'associe à une touche clavier, gamepadBindings (hmi::GamepadBindings) à un bouton manette — chacune remappable indépendamment de l'autre depuis Options (Touches de jeu / Touches de la manette), persistées dans le même Settings/keybindings.json (sections distinctes). Pour chaque action, toPlayerInput vérifie simplement input.keyDown(gameKeyBindings.key(action)) || input.gamepadButtonDown(gamepadBindings.button(action)) : l'une des deux sources suffit, remapper l'une n'affecte jamais l'autre — deux touches/boutons ne sont d'ailleurs jamais partagés par deux actions du même ensemble (GameKeyBindings/GamepadBindings::setKey échangent plutôt que de dupliquer une touche), ce qui évite par construction qu'une touche ne déclenche deux actions à la fois.
toPlayerInput est une fonction pure (aucun état interne, aucun effet de bord) : lui passer les mêmes InputState/bindings produit toujours le même PlayerInput, ce qui la rend testable sans fenêtre ni minuteur, en construisant directement un InputState avec les touches/boutons voulus. Elle est appelée une fois par frame, en amont de toute logique de jeu (EX-CTRL-020, EX-CTRL-021) — jamais à l'intérieur du pas fixe lui-même, pour que tous les pas exécutés dans une même frame (Boucle de jeu et pas de temps fixe) voient exactement la même intention.
InputState ne connaît qu'un seul Key par touche, mais deux sources indépendantes qui peuvent l'enfoncer : le clavier (onKeyDown/onKeyUp) et la manette (onGamepadKeyDown/ onGamepadKeyUp), chacune avec sa propre paire courant/précédent. keyDown/keyPressed/ keyReleased combinent les deux (OU logique) au moment de la lecture — jamais à l'écriture.
Pourquoi pas une seule table partagée ? Parce que la manette est sondée, pas événementielle : hmi::GamepadPoller::poll interroge XInput une fois par frame et doit explicitement relâcher (onGamepadKeyUp) chaque touche dont le bouton correspondant n'est plus enfoncé — y compris quand la manette est débranchée. Si ce relâchement écrivait dans la même table que le clavier, il effacerait une touche clavier réellement maintenue dès que la manette (absente ou relâchée) ne la tient plus. Deux tables, combinées seulement en lecture, rendent ce bug structurellement impossible plutôt que de compter sur la discipline du code appelant.
Chaque bouton/direction manette synthétise le même Key fixe que son équivalent clavier par défaut (D-pad/stick gauche → Left/Right/Up/Down ; A → Enter et Space ; B/ Start → Escape ; épaule droite → Shift, le dash) — câblage en dur dans hmi::GamepadPoller::poll, jamais remappé. Conséquence directe de la fusion : les raccourcis fixes lus dans le viewport (essai de jeu, éditeur) n'ont jamais eu besoin d'être modifiés pour « apprendre » la manette — ils lisent des Key fixes, non remappables (comme Échap/Entrée côté clavier), ce qui satisfait déjà EX-CTRL-010 par construction.
Une seconde piste, brute et indépendante, pour les actions de jeu remappables (LOT-30) : hmi::GamepadPoller::poll alimente aussi, à partir du même relevé XInput, un état brut par hmi::GamepadButton (onGamepadButtonDown/onGamepadButtonUp, InputState::gamepadButtonDown/ gamepadButtonPressed) — dix boutons/directions (D-pad et stick gauche fusionnés en une seule notion logique par direction, comme la piste Key). C'est cette piste, et non la fusion Key ci-dessus, que consulte toPlayerInput via gamepadBindings : la navigation de menu reste sur des Key fixes, la manette côté gameplay devient pleinement configurable sans toucher à la première piste.
Le sondage XInput lui-même (<Xinput.h>) vit dans hmi::GamepadPoller (Input), jamais dans InputState : InputState reste indépendant de toute fenêtre (EX-NFR-010), y compris pour tester la fusion manette ou la piste brute — les tests appellent onGamepadKeyDown/ onGamepadKeyUp/onGamepadButtonDown/onGamepadButtonUp directement, sans manette réelle ni <Windows.h>.
Sondage throttlé quand aucune manette n'est branchée (LOT-33). XInputGetState est notablement coûteux quand le slot interrogé est vide : le pilote énumère les périphériques à chaque appel. Le sonder à chaque frame alors qu'aucune manette n'est connectée provoquait des micro-saccades chez un joueur clavier. hmi::GamepadPoller::poll ne re-sonde donc un slot resté déconnecté qu'une frame sur GAMEPAD_DISCONNECTED_POLL_INTERVAL (≈ 2 s) ; entre-temps l'état reste « déconnecté » (aucune touche synthétique enfoncée). Dès qu'une manette est présente, le sondage redevient systématique — un branchement à chaud est détecté au plus tard après un intervalle.
Refondu au LOT-86. La page Options du jeu est passee en QML (Source/Ui/Screens/Options*) ; la page Qt a onglets decrite ci-dessous n'existe plus. Ce que ses onglets regroupaient – V-Sync, langue, remappage – reste le contenu attendu.
Le menu d'options (accessible depuis le menu principal, Écrans, navigation et boucle de jeu) est, depuis le LOT-38, une page à onglets : la navigation, le défilement et la souris sont pris en charge nativement par Qt — plus aucun modèle de liste « maison ». Ses onglets regroupent la bascule V-Sync (EX-REN-022), le choix de langue (une liste déroulante QComboBox) et le remappage des touches de jeu, des touches d'éditeur et des boutons de manette (sous-sections suivantes). L'état de connexion de la manette y est affiché à titre informatif (InputState::gamepadConnected()).
Les onglets « Touches de jeu »/« Touches de l'éditeur »/« Touches de la manette » de la page Options sont des widgets Qt (hmi::KeybindingsWidget pour le clavier, hmi::GamepadBindingsWidget pour la manette). Chaque ligne affiche une action et sa touche ou son bouton actuel (hmi::keyDisplayName/hmi::gamepadButtonDisplayName) ; déclencher une ligne entre en capture : la frame suivante qui voit une touche ou un bouton assignable pressé (hmi::capturedKey, qui scrute les 256 codes suivis par InputState ; hmi::capturedGamepadButton, qui scrute les dix GamepadButton — Échap/Entrée et B/Start exclus, réservés à la navigation) la lie via setKey ((Game|Editor)KeyBindings ou GamepadBindings), qui échange avec toute autre action déjà sur cette touche/ce bouton plutôt que de rejeter le conflit — jamais deux actions du même ensemble sur la même touche ou le même bouton. Annuler la capture laisse la liaison inchangée. Le remappage manette exige en plus une manette connectée (InputState::gamepadConnected()) pour entrer en capture.
GameKeyBindings/EditorKeyBindings/GamepadBindings sont trois classes séparées (pas de généricité commune) : même mécanique (key/button, setKey, resetToDefaults, load/save), mais des cas concrets distincts, pas de base commune anticipée au-delà. Elles partagent un seul fichier de persistance (Settings/keybindings.json, sections "jeu"/"editeur"/"manette") — chaque save relit le fichier existant pour préserver les sections des deux autres classes plutôt que de les écraser. GameKeyBindings/EditorKeyBindings stockent le code VK brut (Key en est déjà un, voir plus haut) ; GamepadBindings, elle, stocke un nom symbolique ("gauche", "a", "rb", …) — GamepadButton n'a pas d'équivalent numérique naturel comme le VK du clavier, et un nom reste lisible/stable si l'ordre de l'enum change. Un fichier absent, corrompu, ou une entrée invalide (bouton inconnu compris) retombe sur les valeurs par défaut pour l'entrée concernée (EX-NFR-040), jamais bloquant.
Le remappage éditeur ne couvre qu'un sous-ensemble significatif (Sauvegarder/Annuler/Refaire/ Copier/Coller/Test rapide/Grille…), pas l'intégralité des raccourcis de l'éditeur — certains gestes (redimensionnement, Tab) restent câblés en dur (décision de cadrage LOT-29, portée volontairement limitée).
Tous les textes d'interface (menus, options) passent par une clé stable (EX-REN-033) plutôt que par un libellé en dur : hmi::Localization résout une clé (« menu.jouer ») vers la chaîne de la langue active, chargée depuis un fichier <langue>.lang (format clé = valeur, un fichier par langue dans Source/Elements/Localization/). La résolution suit un repli déterministe — langue active, puis langue par défaut, puis la clé elle-même — pour ne jamais planter ni afficher un vide si une traduction manque (EX-NFR-040), au prix, dans ce dernier cas, d'un texte visiblement « brut » (la clé) plutôt qu'un texte manquant silencieux.
Le sélecteur de langue (français ↔ anglais, EX-REN-033) vit dans l'onglet Général de la page Options : une QComboBox. Le changer recharge le catalogue à chaud (Localization::loadLanguage) et retraduit toute l'IHM Qt — hmi::MainWindow détient l'unique hmi::Localization et propage un retranslateUi() à chaque widget (menu, options, docks, palette, navigateur, remappage) ; les libellés de la palette de tuiles sont résolus par une table libellé→clé. La langue choisie est persistée (QSettings) et restaurée au lancement. Un échec de chargement (fichier absent) est récupérable : la langue courante est simplement conservée plutôt que de planter.