前往
大廳
主題

【程式】讀取鍵盤與滑鼠輸入 (Windows)

Shark | 2022-03-02 18:46:11 | 巴幣 2116 | 人氣 1569

暫時離開一下Direct3D和OpenGL,這次介紹作業系統讀取鍵盤和滑鼠輸入的方法,因為要滿足科技需求才能進入下一篇D3D和OpenGL教學。

如果用Windows內建的視窗元件,其實不需要自己處理鍵盤和滑鼠輸入,用滑鼠按按鈕、在文字框裡打字都有專用的事件可以處理,但是寫遊戲程式時,建好頂層視窗後繪圖、處理輸入都要自己做,就要用本篇的方法。
手把輸入則要用另外的方法,以後另寫一篇介紹。

與滑鼠類似的指標裝置,如觸控板、觸控螢幕、繪圖板也可以用滑鼠輸入處理,但不能讀取感壓和多點觸控;如果電腦接兩個鍵盤或指標裝置,本篇的方法也不能分辨是哪個裝置輸入。



以「如何建一個視窗—Windows API篇」為基礎,加上一些東西。

key_mouse.c
#define UNICODE
#include<windows.h>
#include<windowsx.h> //使用GET_X_LPARAM()和GET_Y_LPARAM()
#include<stdio.h>


static int getVK(int lparam){
  lparam=(lparam>>16)&0x1ff; //取出第17~25 bits,包含scan code和extended key bit
  if(lparam & 0x100){ //檢查左右各有一個的按鍵,如Ctrl和Alt
    lparam^=0xe100; //XOR運算,0x138->0xe038
  }
  int vkCode = MapVirtualKey(lparam, MAPVK_VSC_TO_VK_EX);
  return vkCode;
}

LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam){
  switch(message){
  case WM_DESTROY:
    PostQuitMessage(0);
    break;

  //滑鼠輸入
  case
WM_LBUTTONDOWN:
    printf("left button down  cursor:(%d,%d)\n", GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
    break;
  case
WM_LBUTTONUP:
    printf("left button up    cursor:(%d,%d)\n", GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
    break;
  case
WM_RBUTTONDOWN:
    printf("right button down cursor:(%d,%d)\n", GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
    break;
  case
WM_RBUTTONUP:
    printf("right button up   cursor:(%d,%d)\n", GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
    break;
  case
WM_MOUSEWHEEL:
    printf("mouse wheel: %d\n", GET_Y_LPARAM(wparam)/WHEEL_DELTA);
    break;

  //鍵盤輸入,實體按鍵
  case
WM_KEYDOWN:
    printf("keydown    wparam:%4x  lparam:%8x  virtual-key code:%4x\n",
      wparam, lparam, getVK(lparam));
    break;
  case
WM_KEYUP:
    printf("keyup      wparam:%4x  lparam:%8x  virtual-key code:%4x\n",
      wparam, lparam, getVK(lparam));
    break;
  case
WM_SYSKEYDOWN:
    printf("syskeydown wparam:%4x  lparam:%8x  virtual-key code:%4x\n",
      wparam, lparam, getVK(lparam));
    break;
  case
WM_SYSKEYUP:
    printf("syskeyup   wparam:%4x  lparam:%8x  virtual-key code:%4x\n",
      wparam, lparam, getVK(lparam));
    break;

  //鍵盤輸入,打出的字元
  case
WM_CHAR:
    printf("char     char code:%x  lparam:%x\n",wparam,lparam);
    break;
  case
WM_DEADCHAR:
    printf("deadchar char code:%x  lparam:%x\n",wparam,lparam);
    break;

  default:
    return
DefWindowProc(hwnd,message,wparam,lparam);
  }

  return 0;
}

int main(){
  WNDCLASS wndclass;
  ZeroMemory(&wndclass, sizeof(WNDCLASS));
  wndclass.style = CS_HREDRAW|CS_VREDRAW;
  wndclass.lpfnWndProc = WndProc;
  wndclass.hCursor = LoadCursor(NULL,IDC_ARROW);
  wndclass.hbrBackground = (HBRUSH)(COLOR_BTNFACE+1);
  wndclass.lpszClassName = L"window";
  RegisterClass(&wndclass);

  RECT rect={0,0,200,200};
  AdjustWindowRect(&rect,WS_CAPTION|WS_SYSMENU|WS_VISIBLE,0);
  HWND window=CreateWindow(L"window", L"title",
    WS_OVERLAPPED|WS_SYSMENU|WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT,
    rect.right-rect.left, rect.bottom-rect.top,
    NULL,NULL,NULL,NULL);

  MSG msg;
  int ret=0;
  for(;;){
    ret=GetMessage(&msg,NULL,0,0);
    if(ret<=0) break;
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

  return 0;
}

主要是在WndProc()裡增加一些東西。人操作鍵盤和滑鼠的時候,鍵盤和滑鼠的資訊會先送到作業系統,作業系統根據目前狀態,如游標位置、目前作用中視窗判斷由哪一個視窗處理,再把事件送到該視窗的message queue,你寫的程式在WndProc裡處理事件。(之前有說過,視窗不只是top level window,所有GUI元件都是HWND型態)
本篇只用printf()印出一行字,如果要做其他事就請自己做變化。

Windows API裡所有事件的額外資料都是wparam和lparam這兩個,32位元程式裡它們是4 byte整數,64位程式裡是8 byte整數,具體意義依事件種類而異。如果事件的額外資料多於兩個,可能會把數個資料併入一個整數,也有些事件的wparam和lparam是指標指到一個struct(本篇不會用到這種)。

一、滑鼠輸入
本篇處理的滑鼠事件如下,點選事件名稱會開啟MSDN的文件。
WM_LBUTTONDOWN 左鍵按下
WM_LBUTTONUP 左鍵放開
WM_RBUTTONDOWN 右鍵按下
WM_RBUTTONUP 右鍵放開
WM_MOUSEWHEEL 滾輪轉動
由官方文件可以看出每個事件的額外資料都一樣:wparam是一組bit flag表示目前有哪些按鈕被按下;lparam是事件發生時游標在視窗工作區的位置,最低的兩個byte是X坐標,第3,4 byte是Y坐標,用GET_X_LPARAM()和GET_Y_LPARAM()這兩個macro可以取出坐標。
坐標有可能是負數,所以如果要把坐標指定給變數,要用short、int這些有號整數型態。

WM_MOUSEWHEEL另外還有一項資料:轉動的方向和格數,存在wparam的第3,4 byte,用GET_Y_LPARAM()取出再除以WHEEL_DELTA(=120)可以求出。

然後照文件寫的,只要你寫的程式有處理這些訊息,WndProc()要return 0,本程式用break跳出switch,跳到函式末尾的return 0。本篇沒處理的事件就傳給DefWindowProc()做預設的處理。

還有一個事件是WM_MOUSEMOVE,只要游標有移動就會觸發,為了避免printf()印出太多訊息,本篇不處理此事件。

全部能用的事件在這篇,除了本篇介紹的以外還有中鍵、按兩下等等的,有興趣自己試。
MSDN: Mouse Input Notifications

二、鍵盤輸入,實體按鍵
鍵盤事件有分實體按鍵和打出的字元。一個鍵可以打出兩種以上的字元,例如台灣常用的美式鍵盤1和!是同一個鍵,而使用輸入法的時候按好幾個鍵畫面上才會出現一個字元,這些時候實體按鍵事件和字元事件不一樣,實際例子在本篇最後。

本篇處理的實體按鍵事件如下
WM_KEYDOWN 鍵按下
WM_KEYUP 鍵放開
WM_SYSKEYDOWN 系統鍵按下
WM_SYSKEYUP 系統鍵放開
SYSKEYDOWN、SYSKEYUP包括F10、Alt、按住Alt再按另一個鍵,其他按鍵都是KEYDOWN、KEYUP。
wparam、lparam包含的資訊,首先當然有按鍵碼讓你知道是哪個鍵被按下或放開,此外還有一些額外資訊,詳細請參照官方文件。

按鍵碼有scan code和virtual-key code兩種,文件裡寫scan code在不同鍵盤可能會不一樣,應該用virtual-key code判斷是哪個鍵比較好。
MSDN: Virtual-Key Code一覽
文件裡寫wparam是virtual-key code,但是經過實測用wparam來分辨按鍵有以下問題:
1. 左右各有一個的鍵如Shift和Ctrl,無法分辨左右邊。
2. 開啟輸入法的時候不論按哪個鍵wparam都是0xe5,筆者試過中日韓輸入法有此現象,其他語言應該也會。
要像本篇getVK()裡的方法,取出scan code再用MapVirtualKey()取得virtual-key code。

這是鍵盤事件一覽
MSDN: Keyboard Input Notifications

三、鍵盤輸入,打出的字元
首先DispatchMessage()前面有一行「TranslateMessage(&msg);」,它判斷實體按鍵事件會打出什麼字元,如果有打出字就產生WM_CHAR事件放入message queue,沒有這行就不會有WM_CHAR事件。
WM_CHAR 打出的字元
WM_DEADCHAR 死鍵(dead key)
如果程式只需要知道實體按鍵而不需要用輸入法打字,可以把TranslateMessage()拿掉,然後WndProc裡不用處理WM_CHAR。

在一般軟體的文字輸入框按住一個鍵不放,會重覆出現同一個字元,底層的事件處理也一樣,按住一個鍵不放會產生一連串WM_KEYDOWN和WM_CHAR事件,放開時才產生WM_KEYUP事件。
X Window自動重覆的事件和Windows不一樣,詳細見X Window篇教學。

至於WM_DEADCHAR,台灣的鍵盤沒這種鍵,有些歐洲語言的鍵盤有,用來打出有變音符號的字母如Ê、Ë、É、È。
例如法語的AZERTY鍵盤。 Wikipedia: AZERTY

用這個指令build。
cl key_mouse.c /Fekey_mouse.exe /O2 /MD /link user32.lib

執行時按按看各個滑鼠和鍵盤按鍵

如果視窗不是作用中(用滑鼠點一下視窗外面,讓標題變成灰色),則程式不會收到鍵盤和滑鼠事件。



上面說到實體按鍵和字元是不同的事件,試試幾個例子

範例1:按下Q鍵。CapsLock燈沒亮的時候printf()輸出如下
(key code和字碼都是16進位)
printf()輸出 key code或char code的意義
keydown    wparam:  51  lparam:  100001  virtual-key code:  51 51: Q
char     char code:71  lparam:100001 71: q
keyup      wparam:  51  lparam:c0100001 51: Q
CapsLock燈亮的時候如下
keydown    wparam:  51  lparam:  100001  virtual-key code:  51
51: Q
char     char code:51  lparam:100001
51: Q
keyup      wparam:  51  lparam:c0100001  virtual-key code:  51 51: Q
key code同樣是0x51,等於ascii code的大寫Q,但char code不一樣。

範例2:按1
keydown    wparam:  31  lparam:   20001  virtual-key code:  31 31: 1
char     char code:31  lparam:20001 31: 1
keyup      wparam:  31  lparam:c0020001  virtual-key code:  31 31: 1
按住Shift鍵再按1
keydown    wparam:  10  lparam:  2a0001  virtual-key code:  a0 a0: 左Shift
keydown    wparam:  31  lparam:   20001  virtual-key code:  31 31: 1
char     char code:21  lparam:20001 21: !
keyup      wparam:  31  lparam:c0020001  virtual-key code:  31 31: 1
keyup      wparam:  10  lparam:c02a0001  virtual-key code:  a0 a0: 左Shift
key code相同但char code不一樣。

範例3:輸入法是倉頡的時候,按下「H、A、P、I、空白」(字根:竹日心戈),會印出以下訊息
keydown    wparam:  e5  lparam:  230001  virtual-key code:  48
keyup      wparam:  48  lparam:c0230001  virtual-key code:  48
48: H
keydown    wparam:  e5  lparam:  1e0001  virtual-key code:  41
keyup      wparam:  41  lparam:c01e0001  virtual-key code:  41
41: A
keydown    wparam:  e5  lparam:  190001  virtual-key code:  50
keyup      wparam:  50  lparam:c0190001  virtual-key code:  50
50: P
keydown    wparam:  e5  lparam:  170001  virtual-key code:  49
keyup      wparam:  49  lparam:c0170001  virtual-key code:  49
49: I
keydown    wparam:  e5  lparam:  390001  virtual-key code:  20 20: 空白鍵
char     char code:7684  lparam:1 7684: 「的」的Unicode字碼
keyup      wparam:  20  lparam:c0390001  virtual-key code:  20 20: 空白鍵
按5個鍵畫面上才出現一個字元。
上面說過開啟中文輸入法的時候wparam都是0xe5,這裡可以看出來。

鍵盤、滑鼠、還有之後要介紹的手把輸入,有很多細節官方文件也沒寫,是筆者自己寫程式、把輸入裝置用所有方法操作一遍、用printf()印出內部數值,才試出來。
像是getVK()裡面為什麼要把lparam跟0xe100做xor運算,是寫一個程式把鍵盤上所有按鍵都試一下才弄清楚規則。
鍵盤碼與按鈕名稱對應表



據我所知,Windows讀取鍵盤和滑鼠輸入有以下幾種方法(可能還有其他我不知道的方法):
  1. 接收key和mouse訊息,本篇介紹的方法。
  2. 用以下的函式。window message的方法是輸入裝置有變化的時候系統才傳訊息給程式,這些函式是主動取得按鍵狀態和游標位置。
    MSDN: GetKeyboardState()
    MSDN: GetKeyState()
    MSDN: GetAsyncKeyState()
    MSDN: GetCursorPos()
    照官方說明,GetKeyboardState()和GetKeyState()不是直接讀取硬體狀態,而是WndProc()處理事件後將狀態暫存在程式裡,這兩個函式讀取暫存的狀態。
  3. 接收raw input訊息。可以讀取多個鍵盤或指標裝置,也能支援鍵盤和滑鼠以外的裝置,但做法比較麻煩。
    MSDN: Raw Input
  4. DirectInput。類似2.主動讀取狀態,可以支援多個鍵盤或指標裝置。
    早期可能因為電腦不夠快,以上3種方法會有延遲,所以特別設計一個比較直接存取系統的方法,但現在的電腦已經夠快,以上3種方法也沒有明顯延遲,而且比DirectInput簡便,現在已經不太有必要使用DirectInput。

創作回應

相關創作

更多創作