Chapitre -122 · keyboard

Inputs — clavier

Les inputs sur macOS passent par NSEvent — les événements système qu'AppKit distribue à la fenêtre active — il n'existe pas de wrapper C++. L'architecture propre isole ces événements dans une structure InputState C++ que le Renderer consomme chaque frame, sans jamais toucher à ObjC dans la logique de jeu. Pour les manettes on utilise GCController du Framework GameController

Architecture — le flux de données

NSEvent
keyDown / mouseMoved
→
GameWindow
_pressedKeys + _mouseDrag
→
pollInputState()
snapshot par frame
→
Renderer
C++ pur

Afin d'avoir une application codée en langage C++ au maximum, nous avons vu comment appeler nos fonctions draw() et resize() depuis ObjC, le point de départ de l'exécution. Ici, dans un premier temps tu vas récupérer l'état des touches (0 = false, 1 = true) via les notifications mise en place par le système macOS.

Une structure partagée Objective-C / C++

Définie dans notre nouveau fichier InputState.h, partagée via un include simple — pas de cast, pas de bridge.

/// C++ — includes/SharedAPP/InputState.h — update:18/05/26
#ifndef InputState_h
#define InputState_h

#include "simd/simd.h"

struct InputState
{   // État continu : reconstruit intégralement à chaque frame,
    // donc rien à remettre à false soi-même
    bool keyLeft  = false;
    bool keyRight = false;
    bool keySpace = false;
    bool keyA     = false;
    bool keyZ     = false;
    bool keyE     = false;
    bool keyQ     = false;
    bool keyS     = false;
    bool keyD     = false;

    // Multiplicateur continu : 1.0 au repos
    float         speedMultiplier = 1.0f;
};

#endif /* InputState_h */
État continu vs action ponctuelle

Tous les champs ci-dessus sont de l'état : ils décrivent ce qui est enfoncé maintenant, et pollInputState() repart d'un InputState neuf à chaque frame. Une vraie action (sauter, tirer) est différente : elle doit être vraie exactement une frame, puis remise à false par celui qui l'a lue — sinon le personnage saute tant que la touche reste enfoncée.

RenderWindow — capturer les événements

RenderWindow est notre sous-classe de NSWindow. Elle maintient un NSMutableSet des touches enfoncées et accumule le delta souris entre deux appels à pollInputState().

On déclare une fonction ici nommée pollInputState qui renvoie nos inputs sous forme de structure nommée ici InputState

/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
#include "../includes/SharedAPP/InputState.h"

@interface RenderWindow : NSWindow

@property (weak) ApplicationController* appCoordinator;

- (InputState)pollInputState; // Ajout à notre objet/class RenderWindow

@end
/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
@implementation RenderWindow // Constructeur RenderWindow
{   // déclaration de ivars
    NSMutableSet<NSNumber*>* _pressedKeys;   // touches actuellement enfoncées
    BOOL _commandWasDown;                // pour rattraper les keyUp: perdus (voir plus bas)
}
/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
- (instancetype)initWithContentRect:(NSRect)rect
                          styleMask:(NSWindowStyleMask)mask
                            backing:(NSBackingStoreType)backing
                              defer:(BOOL)flag
                             screen:(NSScreen*)screen
{
    self = [super initWithContentRect:rect styleMask:mask backing:backing defer:flag screen:screen];
    if (self) { _pressedKeys = [NSMutableSet new]; }
    return self;
}
/// Objective-C++ — AppViewController.mm — update:18/05/26
// Requis pour recevoir les événements clavier (les 3)
- (BOOL)acceptsFirstResponder               { return YES; }
- (BOOL)canBecomeKeyWindow                  { return YES; }
- (BOOL)canBecomeMainWindow                 { return YES; }
// Requis pour recevoir les événements souris
- (BOOL)acceptsFirstMouse:(NSEvent *)event  { return YES; }

keyDown / keyUp — le set de touches

Le pattern NSMutableSet est la façon correcte de gérer les touches : on ajoute dans keyDown, on retire dans keyUp — à chaque événement, pas à chaque frame. Le set reflète en permanence l'état réel du clavier ; pollInputState() se contente de le lire une fois par frame. Deux touches enfoncées en même temps = deux entrées dans le set, sans effort.

/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
- (void)keyDown:(NSEvent *)event
{   // isARepeat = YES (true) quand macOS répète une touche maintenue
    if (!event.isARepeat)
        [_pressedKeys addObject:@(event.keyCode)];
}

- (void)keyUp:(NSEvent *)event
{
    [_pressedKeys removeObject:@(event.keyCode)];
}

flagsChanged — Shift et Ctrl

Les touches modificatrices (⇧ Shift, ⌃ Ctrl, ⌥ Option, ⌘ Command) ne déclenchent jamais keyDown — elles utilisent flagsChanged(). On les mappe sur des keycodes virtuels (0x80, 0x81) pour les inclure dans le même set.

Pourquoi 0x80 et au-dessus ? Parce que les keycodes matériels de macOS s'arrêtent à 0x7E (flèche haut) : tout ce qui est >= 0x80 est libre, aucune collision possible avec une vraie touche. Et pourquoi un code par modificateur plutôt qu'un par touche physique ? Parce que modifierFlags ne dit pas quel Shift est enfoncé — pour ça il faudrait tester le keyCode de l'événement (0x38 ou 0x3C).

/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
// Petit utilitaire : pose ou retire un code selon un booléen
- (void)setKey:(uint16_t)code held:(BOOL)held
{
    if (held) [_pressedKeys addObject:@(code)];
    else      [_pressedKeys removeObject:@(code)];
}

- (void)flagsChanged:(NSEvent *)event
{
    NSEventModifierFlags f = event.modifierFlags;

    [self setKey:0x80 held:(f & NSEventModifierFlagShift)   != 0];  // ⇧
    [self setKey:0x81 held:(f & NSEventModifierFlagControl) != 0];  // ⌃
    [self setKey:0x82 held:(f & NSEventModifierFlagOption)  != 0];  // ⌥
    [self setKey:0x83 held:(f & NSEventModifierFlagCommand) != 0];  // ⌘

    // ⌘ vient d'être relâché : les keyUp: avalés pendant ce temps
    // n'arriveront jamais, on relâche tout à la main (voir ci-dessous)
    BOOL cmd = (f & NSEventModifierFlagCommand) != 0;
    if (_commandWasDown && !cmd) [self releaseAllHardwareKeys];
    _commandWasDown = cmd;
}
/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
- (void)flagsChanged:(NSEvent *)event
{
    if (event.modifierFlags & NSEventModifierFlagShift)
        [_pressedKeys addObject:@(0x80)];
    else
        [_pressedKeys removeObject:@(0x80)];

    if (event.modifierFlags & NSEventModifierFlagControl)
        [_pressedKeys addObject:@(0x81)];
    else
        [_pressedKeys removeObject:@(0x81)];
}

Le piège du ⌘ — des touches qui restent coincées

Deux situations font perdre des keyUp : tant que ⌘ Command est enfoncé, macOS ne délivre pas les keyUp des autres touches ; et une fenêtre qui perd le focus (⌘-Tab) cesse de recevoir des événements clavier. Dans les deux cas le set garde des touches « enfoncées » à vie — le personnage part tout seul vers la gauche pour toujours. Le correctif tient en deux méthodes.

/// Objective-C++ — AppViewController.mm — RenderWindow
// On ne relâche que les vraies touches : les codes virtuels >= 0x80
// restent gérés par flagsChanged, qui lui continue d'être appelé
- (void)releaseAllHardwareKeys
{
    NSMutableSet<NSNumber*>* keep = [NSMutableSet new];
    for (NSNumber* k in _pressedKeys)
        if (k.unsignedShortValue >= 0x80) [keep addObject:k];
    [_pressedKeys setSet:keep];
}

// La fenêtre perd le focus : plus aucun keyUp: ne viendra, on vide tout
- (void)resignKeyWindow
{
    [super resignKeyWindow];
    [_pressedKeys removeAllObjects];
    _commandWasDown = NO;
}
Architecture propre
ObjC reçoit les événements système et les transfère au Renderer C++ via des appels simples. Le Renderer ne voit jamais NSEvent, MTKView, ou quoi que ce soit ObjC. Cette séparation permet de remplacer la couche ObjC par SDL, GLFW ou autre sans toucher au Renderer, ni au performance/décalage de frames.

pollInputState() — snapshot par frame

Appelée une fois par frame dans drawInMTKView:, elle construit un InputState propre depuis l'état courant du set de touches.

/// Objective-C++ — AppViewController.mm — update:18/05/26 — RenderWindow
- (InputState)pollInputState
{
    InputState state; // tout est déjà à false / 1.0f
    if ([_pressedKeys containsObject:@(0x7B)]) { state.keyLeft  = true; }
    if ([_pressedKeys containsObject:@(0x7C)]) { state.keyRight = true; }
    if ([_pressedKeys containsObject:@(0x31)]) { state.keySpace = true; }
    if ([_pressedKeys containsObject:@(0x80)]) { state.speedMultiplier = 1.89f; }
    if ([_pressedKeys containsObject:@(0x81)]) { /* Do something */ }
    if ([_pressedKeys containsObject:@(0x35)]) { [[NSApplication sharedApplication] terminate:nil]; }
    return state;
}

Esc dans notre cas va chercher le sélecteur terminate de notre application afin de stopper la boucle.

drawInMTKView — consommer l'état

C'est ici que l'état est appliqué à la caméra et transmis au GameCoordinator. L'intégration avec le delta time rend le mouvement frame-rate indépendant.

/// Objective-C++ — AppViewController.mm — update:18/05/26 — ApplicationController
- (void)drawInMTKView:(nonnull MTKView *)view
{   // Delta time — cappé à 100ms pour éviter les sauts après suspend
    static CFTimeInterval lastTime = CACurrentMediaTime();
    CFTimeInterval now = CACurrentMediaTime();
    float delta = (float)(now - lastTime);
    lastTime = now;
    delta = fminf(delta, 0.1f);
    // Snapshot des inputs
    InputState input = [_window pollInputState];
    _renderer->m_input = input;
    _renderer->draw((__bridge MTK::View *)view, now, delta);
}

Ta classe principale :

/// C++ — Renderer.hpp — update:18/05/26
#include "../includes/SharedGPU/Renderer_shared.h"
#include "../includes/SharedAPP/InputState.h" // ← inclus

public: // ← en public
    ~Renderer();
    InputState m_input;
⬡CheckPoint Compilation — pas d'impact visuel

Table des keycodes macOS

Les keycodes sont des valeurs matérielles — ils ne changent pas selon la langue du clavier. Indépendants de la disposition AZERTY/QWERTY.

Change la langue du site en anglais pour les Keycodes QWERTY.

Code Touche Code Touche
0x00Q 0x0BB
0x01S 0x0CA
0x02D 0x0DZ
0x03F 0x0EE
0x06W 0x0FR
0x08C
0x11T
0x22I
0x30Tab
0x31Espace
0x35Escape
0x80les 2 Shifts 0x81Control
0x7B← Gauche
0x7C→ Droite
0x7E↑ Haut
0x7D↓ Bas
AZERTY — les keycodes ne bougent pas
Sur un clavier AZERTY, la touche physique à gauche de S a le keycode 0x00 — qui correspond à la position de A sur QWERTY. Autrement dit, en AZERTY, 0x00 c'est Q, 0x0C c'est A, 0x0D c'est Z, 0x06 c'est W. Les contrôles WASD/ZQSD fonctionnent naturellement si on mappe par keycode et non par caractère.

Table des keycodes macOS — AZERTY

Un keycode désigne une position physique sur le clavier, jamais le caractère imprimé dessus. La touche à droite du Tab renvoie 0x0C qu'on soit en AZERTY, en QWERTY ou en Dvorak — seule la lettre gravée change. Les constantes correspondantes vivent dans <Carbon/HIToolbox/Events.h> et sont nommées d'après le QWERTY US : kVK_ANSI_Q vaut 0x0C, c'est-à-dire le A d'un AZERTY.

Colonne AZERTY = ce qui est gravé sur un clavier français Apple ; le caractère en gris est celui obtenu avec ⇧ Shift. La colonne QWERTY donne la même touche physique vue par un clavier US.

Lettres — rangée du haut

CodeAZERTYQWERTYConstante Carbon
0x0CAQkVK_ANSI_Q
0x0DZWkVK_ANSI_W
0x0EEEkVK_ANSI_E
0x0FRRkVK_ANSI_R
0x11TTkVK_ANSI_T
0x10YYkVK_ANSI_Y
0x20UUkVK_ANSI_U
0x22IIkVK_ANSI_I
0x1FOOkVK_ANSI_O
0x23PPkVK_ANSI_P
0x21^ ¨[ {kVK_ANSI_LeftBracket
0x1E$ *] }kVK_ANSI_RightBracket

Lettres — rangée du milieu

CodeAZERTYQWERTYConstante Carbon
0x00QAkVK_ANSI_A
0x01SSkVK_ANSI_S
0x02DDkVK_ANSI_D
0x03FFkVK_ANSI_F
0x05GGkVK_ANSI_G
0x04HHkVK_ANSI_H
0x26JJkVK_ANSI_J
0x28KKkVK_ANSI_K
0x25LLkVK_ANSI_L
0x29M; :kVK_ANSI_Semicolon
0x27ù %' "kVK_ANSI_Quote
0x2A` £\ |kVK_ANSI_Backslash

Lettres — rangée du bas

CodeAZERTYQWERTYConstante Carbon
0x0A< >—kVK_ISO_Section
0x06WZkVK_ANSI_Z
0x07XXkVK_ANSI_X
0x08CCkVK_ANSI_C
0x09VVkVK_ANSI_V
0x0BBBkVK_ANSI_B
0x2DNNkVK_ANSI_N
0x2E, ?MkVK_ANSI_M
0x2B; ., <kVK_ANSI_Comma
0x2F: /. >kVK_ANSI_Period
0x2C= +/ ?kVK_ANSI_Slash

Rangée des chiffres

CodeAZERTYQWERTYConstante Carbon
0x32@ #` ~kVK_ANSI_Grave
0x12& 11 !kVK_ANSI_1
0x13é 22 @kVK_ANSI_2
0x14" 33 #kVK_ANSI_3
0x15' 44 $kVK_ANSI_4
0x17( 55 %kVK_ANSI_5
0x16§ 66 ^kVK_ANSI_6
0x1Aè 77 &kVK_ANSI_7
0x1C! 88 *kVK_ANSI_8
0x19ç 99 (kVK_ANSI_9
0x1Dà 00 )kVK_ANSI_0
0x1B) °- _kVK_ANSI_Minus
0x18- _= +kVK_ANSI_Equal

Touches spéciales & navigation

CodeToucheConstante Carbon
0x24Return ↵kVK_Return
0x30Tab ⇥kVK_Tab
0x31EspacekVK_Space
0x33⌫ Retour arrièrekVK_Delete
0x35⎋ EscapekVK_Escape
0x72Aide / InsertkVK_Help
0x73↖ DébutkVK_Home
0x74⇞ Page précédentekVK_PageUp
0x75⌦ Suppression avantkVK_ForwardDelete
0x77↘ FinkVK_End
0x79⇟ Page suivantekVK_PageDown
0x7B← GauchekVK_LeftArrow
0x7C→ DroitekVK_RightArrow
0x7D↓ BaskVK_DownArrow
0x7E↑ HautkVK_UpArrow

Modificateurs — reçus par flagsChanged

CodeToucheConstante Carbon
0x36⌘ Command droitekVK_RightCommand
0x37⌘ Command gauchekVK_Command
0x38⇧ Shift gauchekVK_Shift
0x39⇪ Verr. majusculekVK_CapsLock
0x3A⌥ Option gauchekVK_Option
0x3B⌃ Control gauchekVK_Control
0x3C⇧ Shift droitekVK_RightShift
0x3D⌥ Option droitekVK_RightOption
0x3E⌃ Control droitekVK_RightControl
0x3FfnkVK_Function

Nos codes virtuels — hors plage matérielle

CodeToucheDéfini par
0x80⇧ Shift (les deux)flagsChanged
0x81⌃ Control (les deux)flagsChanged
0x82⌥ Option (les deux)flagsChanged
0x83⌘ Command (les deux)flagsChanged

Pavé numérique

CodeToucheConstante Carbon
0x520 (pavé)kVK_ANSI_Keypad0
0x531 (pavé)kVK_ANSI_Keypad1
0x542 (pavé)kVK_ANSI_Keypad2
0x553 (pavé)kVK_ANSI_Keypad3
0x564 (pavé)kVK_ANSI_Keypad4
0x575 (pavé)kVK_ANSI_Keypad5
0x586 (pavé)kVK_ANSI_Keypad6
0x597 (pavé)kVK_ANSI_Keypad7
0x5B8 (pavé)kVK_ANSI_Keypad8
0x5C9 (pavé)kVK_ANSI_Keypad9
0x41. (pavé)kVK_ANSI_KeypadDecimal
0x43* (pavé)kVK_ANSI_KeypadMultiply
0x45+ (pavé)kVK_ANSI_KeypadPlus
0x4E- (pavé)kVK_ANSI_KeypadMinus
0x4B/ (pavé)kVK_ANSI_KeypadDivide
0x51= (pavé)kVK_ANSI_KeypadEquals
0x47Clear / Verr. numkVK_ANSI_KeypadClear
0x4C⌤ Enter (pavé)kVK_ANSI_KeypadEnter

Touches de fonction & volume

CodeToucheConstante Carbon
0x7AF1kVK_F1
0x78F2kVK_F2
0x63F3kVK_F3
0x76F4kVK_F4
0x60F5kVK_F5
0x61F6kVK_F6
0x62F7kVK_F7
0x64F8kVK_F8
0x65F9kVK_F9
0x6DF10kVK_F10
0x67F11kVK_F11
0x6FF12kVK_F12
0x69F13kVK_F13
0x6BF14kVK_F14
0x71F15kVK_F15
0x6AF16kVK_F16
0x40F17kVK_F17
0x4FF18kVK_F18
0x50F19kVK_F19
0x5AF20kVK_F20
0x48Volume +kVK_VolumeUp
0x49Volume −kVK_VolumeDown
0x4AMuetkVK_Mute
AZERTY — les keycodes ne bougent pas
Sur un clavier AZERTY, la touche physique à gauche de S renvoie 0x00 — la position du A d'un QWERTY. Autrement dit : 0x00 c'est Q, 0x0C c'est A, 0x0D c'est Z, 0x06 c'est W, 0x29 c'est M. En mappant par keycode, les contrôles ZQSD tombent exactement sur les mêmes touches physiques que WASD — sans une ligne de code en plus, et sans jamais lire le caractère.
ISO ≠ ANSI — la touche en plus
Les claviers européens (dont les AZERTY) sont au format ISO : ils ont une touche de plus que les ANSI américains, celle coincée entre ⇧ Shift gauche et le W. Elle renvoie 0x0A (kVK_ISO_Section) et n'existe tout simplement pas sur un clavier US — ne t'en sers jamais pour une commande obligatoire. À l'autre bout, la touche à gauche du 1 (@ # en AZERTY Apple, ` ~ en QWERTY) est 0x32. Les claviers japonais (JIS) ajoutent encore d'autres codes, absents de la table : 0x5D ¥, 0x5E _, 0x66 英数, 0x68 かな.
Deux pièges de lecture de la table
1. Les codes ne suivent aucun ordre logique : 0x16 c'est le 6 mais 0x17 c'est le 5, 0x19 le 9 et 0x1A le 7. Jamais de calcul du genre 0x12 + n pour atteindre le chiffre n — toujours passer par la table.
2. AZERTY Apple ≠ AZERTY PC sur quatre touches de ponctuation — la table ci-dessus suit le clavier Apple : 0x1C porte ! 8 chez Apple contre _ 8 sur PC, 0x1E $ * contre $ £, 0x2A ` £ contre * µ, et 0x2C = + contre ! §. Les lettres, les chiffres et les accentuées sont identiques des deux côtés — et le keycode, lui, ne bouge dans aucun cas.

Vérifier un code en trois lignes

La table couvre le clavier standard, mais le plus rapide reste de faire parler la machine : ajoute temporairement un NSLog dans keyDown et appuie sur la touche qui t'intéresse.

/// Objective-C++ — debug — à retirer ensuite
- (void)keyDown:(NSEvent *)event
{
    NSLog(@"keyCode = 0x%02X   caractère = %@",
          event.keyCode, event.charactersIgnoringModifiers);
}

Du keycode au libellé à afficher

Un écran de configuration des touches ne peut pas afficher « 0x0C » à l'utilisateur, ni écrire « Q » en dur : il faut demander au système ce que cette position produit sur le clavier actuellement actif. C'est le rôle de UCKeyTranslate, dans le framework Carbon (à ajouter aux Link Binary With Libraries).

/// Objective-C++ — keycode → libellé affichable
#import <Carbon/Carbon.h>

NSString* LabelForKeyCode(uint16_t keyCode)
{
    TISInputSourceRef src    = TISCopyCurrentKeyboardLayoutInputSource();
    CFDataRef         data   = (CFDataRef)TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData);
    const UCKeyboardLayout* layout = (const UCKeyboardLayout*)CFDataGetBytePtr(data);

    UInt32       deadKeyState = 0;
    UniChar      chars[4];
    UniCharCount length = 0;

    UCKeyTranslate(layout, keyCode, kUCKeyActionDisplay, 0,
                   LMGetKbdType(), kUCKeyTranslateNoDeadKeysBit,
                   &deadKeyState, sizeof(chars) / sizeof(chars[0]), &length, chars);

    CFRelease(src);
    return [[NSString stringWithCharacters:chars length:length] uppercaseString];
}
// LabelForKeyCode(0x0C) → "A" sur un AZERTY, "Q" sur un QWERTY
La bonne règle
Keycode pour la logique, libellé pour l'affichage. Le jeu teste 0x0D ; l'interface écrit « Z » ou « W » selon le clavier branché. Mélanger les deux — tester characters au lieu du keyCode — c'est se retrouver avec des commandes qui changent de place d'un utilisateur à l'autre, et qui cassent dès qu'un modificateur est enfoncé.