主題

【程式】讀取手把輸入 (Windows)

Shark | 2022-04-12 12:16:02 | 巴幣 1016 | 人氣 182

前篇:讀取鍵盤與滑鼠輸入 (Windows)
本篇介紹手把輸入。

程式教學目錄



在寫程式之前,先看看Windows內部的手把訊息是什麼。
把手把的線插進USB孔或設定好藍牙連接後,照下面操作。

進入控制台,選「裝置和印表機」。


在手把上按右鍵,選「遊戲控制器設定」。


然後按「內容」。


會出現這個畫面。此時操作手把上的按鈕或類比桿,會顯示Windows收到的訊息。


Windows的手把輸入有三種資料
  1. 按鈕(Button)
    每個按鈕都是一個on、off的開關,這比較好處理。
    有些手把的類比桿可以按下,這也算是按鈕。
  2. 軸(Axis)
    十字鈕與類比桿屬於這種。X軸是左右,Y軸是上下不用解釋,如果有兩個以上的十字鈕或類比桿則會出現其他名稱。從下面的joyGetPosEx()說明可以得知軸的名稱來自物理學的三軸移動和三軸旋轉:X、Y、Z、X旋轉、Y旋轉、Z旋轉,一般手把不能輸入物理學的六軸,倒是現在多數手把都有複數個十字鈕和類比桿,就把軸的名稱用在這裡。
    值是0~65535,代表推類比桿的程度,中間位置是32767,最左或最上是0,最右、下是65535。
    十字鈕雖然沒有力度控制但也是當成軸處理,數值只有0、32767、65535三種情況。
  3. 視覺頭盔(Point of view, POV)
    一般手把並沒有視覺頭盔的功能,筆者也沒用過,通常都是其中一組十字鈕或類比桿對應到視覺頭盔。
    值是0~359度的角度×100,上方為0順時針為正,不過十字鈕或類比桿對應到視覺頭盔的時候,實際有的值是0、4500、9000等間隔45度的八個方向,沒輸入的時候是65535。
有的手把有類比按鈕(例如Xbox手把),按得淺或深效果不一樣,Windows偵測到的可能會是1或2,如果是1那就不能使用按鈕深淺的功能。
(筆者有類比按鈕的手把已經損壞丟掉,是憑記憶寫的,不確定對不對)

Logitech F310有個MODE按鈕,按了可讓旁邊的指示燈亮或暗,燈暗的時候左類比桿是X、Y軸,十字鈕是POV,燈亮時則是兩者交換。
https://www.logitechg.com/en-us/products/gamepads/f310-gamepad.940-000110.html

可以看出這些內部資料和真的手把差很多,可能因為這個標準是Windows 3.1的時代定的,現在硬體已經有很大的變化。

本篇介紹的方法能支援同時插16個手把,每個手把32個按鈕和8軸,其中6個軸對應到軸,兩個軸對應到POV。

這是Windows版的snes9x按鈕設定,有些看起來奇怪的按鈕名稱,為什麼按十字鈕這裡顯示的是POV?為什麼推右類比桿會顯示Z軸、R軸?為什麼設定Up和Left之後,按十字鈕的左+上也不能往斜方向移動,要把Up Left這一格設成「POV Up Left」才有效?要看內部資料才能解答。

(十字鈕斜方向沒有反應的原因是,因為十字鈕內部資料是視覺頭盔,按左上的話Windows收到的訊息不是「左+上」,而是「角度=315度」)



這次不需要建視窗,寫個命令列程式就行了。

joystick.c
#define UNICODE
#include<windows.h>
#include<stdio.h>
#include<locale.h>


const int SLEEP_INTERVAL=20; //50 FPS

typedef struct{
  DWORD button;
  DWORD axis[6];
  DWORD pov;
} JoystickState;

//函式傳回手把編號
//buttonNum傳回按鈕數,讀取手把輸入時會用到

static int findJoystick(int* buttonNum){
  int joyId=-1;
  JOYCAPS joycaps;
  MMRESULT ret;
  for(int i=0;i<16;i++){
    JOYINFO joyinfo;
    ret=joyGetPos(i, &joyinfo);
    if(ret!=JOYERR_NOERROR){
      continue;
    }
    ret=joyGetDevCaps(i, &joycaps, sizeof(JOYCAPS));
    if(ret!=JOYERR_NOERROR){
      continue;
    }

    joyId=i; //找到手把
    break;
  }
  if(joyId<0){
    return -1;
  }

  *buttonNum=joycaps.wNumButtons;
  //印出手把資訊
  printf("--find joystick %d\n",joyId);
  printf("name:%S\nbuttons:%d axes:%d\n",joycaps.szPname,joycaps.wNumButtons,joycaps.wNumAxes);
  printf("caps %x\n",joycaps.wCaps);
  printf("has POV %x\n",joycaps.wCaps&JOYCAPS_HASPOV);
  printf("POV is direction %x\n",joycaps.wCaps&JOYCAPS_POV4DIR);
  printf("POV is degree %x\n",joycaps.wCaps&JOYCAPS_POVCTS);
  return joyId;
}

//手把拔出則傳回0,此時要重新尋找手把
static int readJoystickState(int joyId, int buttonNum, JoystickState* prevState){
  MMRESULT ret;
  JOYINFOEX joyinfo;
  joyinfo.dwSize=sizeof(JOYINFOEX);
  joyinfo.dwFlags=JOY_RETURNALL;
  ret=joyGetPosEx(joyId, &joyinfo);
  if(ret!=JOYERR_NOERROR){ //手把拔出,重新搜尋
    printf("--joystick is removed\n");
    return 0;
  }

  //跟前一個frame的狀態比對,找出有變化的輸入
  //按鈕

  DWORD bitFlag=1;
  DWORD changedButtons = prevState->button^joyinfo.dwButtons;
  for(int i=1; i<=buttonNum; i++,bitFlag=bitFlag<<1){
    if(!(changedButtons&bitFlag)){ continue; }
    if(joyinfo.dwButtons&bitFlag){
      printf("button %d: press\n",i);
    }else{
      printf("button %d: release\n",i);
    }
  }
  //軸
  DWORD* joyinfoAxis=&joyinfo.dwXpos;
  for(int i=0;i<6;i++){
    if(prevState->axis[i]!=joyinfoAxis[i]){
      printf("axis %d: %d\n", i, joyinfoAxis[i]);
    }
  }
  //視覺頭盔
  if(prevState->pov!=joyinfo.dwPOV){
    printf("POV: %u\n",joyinfo.dwPOV);
  }

  //把這個frame的狀態保存
  prevState->button=joyinfo.dwButtons;
  prevState->pov=joyinfo.dwPOV;
  memcpy(prevState->axis, joyinfoAxis, sizeof(DWORD)*6);
  return 1;
}

int main(){
  setlocale(LC_ALL,"cht"); //讓printf可以印出中文
  int joyId=-1;
  int buttonNum;
  for(;;Sleep(SLEEP_INTERVAL)){
    //一、尋找手把
    joyId=findJoystick(&buttonNum);
    if(joyId==-1){ //找不到手把
      continue;
    }

    //二、讀取手把輸入
    JoystickState prevState; //用來記錄前一個frame的狀態
    ZeroMemory(&prevState, sizeof(JoystickState));
    //手把拔出才離開這個迴圈
    while(readJoystickState(joyId, buttonNum, &prevState)){
      Sleep(SLEEP_INTERVAL);
    }
    joyId=-1;
  }
  return 0;
}

用無窮迴圈,每隔20ms跑一次邏輯。分成兩個部分,第一部分偵測現在有沒有連接手把,如果有則把編號(id)記下來,第二部分用另一個迴圈讀取輸入。本程式可以偵測手把拔出再重新插入的情況。
為了避免縮排過多,主要邏輯寫在兩個函式裡,把程式碼嵌入main()裡面也可以達到相同效果。
如果連接兩個以上的手把,本程式只能讀取其中一個,如何讀取複數個手把就請自己研究。

本程式沒有離開迴圈的方法,請按Ctrl+C或Ctrl+Break結束程式。

首先找到Windows SDK裡的joystickapi.h,本篇用到的常數值要在這裡查。筆者目前用的Visual C++ 2013在此,其他版本的Visual C++就把版本號數字換一下。
C:\Program Files (x86)\Windows Kits\8.1\Include\um\joystickapi.h
這個也可以參考,MSDN關於手把的章節。
MSDN: Joysticks

一、尋找手把
用joyGetPos()尋找目前連接的手把,第一參數填編號,可以填0~15,如果這個id有對應到手把則傳回JOYERR_NOERROR (=0),否則會傳回其他值。
並沒有一個函式可以呼叫後傳回哪些id能用,只能id填0~15一個一個試。
MSDN: joyGetPos()說明

接下來用joyGetDevCaps()取得手把資訊,資訊會存入struct JOYCAPS,包括幾個按鈕、幾個軸、有沒有POV等等,詳細見官方文件。
MSDN: joyGetDevCaps()說明
MSDN: struct JOYCAPSW說明
(這個struct有A和W的版本。在「如何建一個視窗—Windows API篇」提過,Windows API包含字串的函式和struct都有兩個版本,程式開頭寫#define UNICODE則之後會用W的版本,反之會用A的版本。)
為何不直接用joyGetDevCaps()檢查id能不能用,還要呼叫joyGetPos()?經筆者測試,如果程式執行途中把手把拔出,joyGetDevCaps()仍然會傳回JOYERR_NOERROR並傳回先前手把的資訊,只有joyGetPos()和joyGetPosEx()可以偵測手把拔出。

wMaxButtons、wMaxAxes筆者試了不同手把都一定會傳回32和6,好像是指系統最多能支援幾個按鈕和軸,想得知目前手把有幾個按鈕和軸要用wNumButtons和wNumAxes。

wXmin、wXmax等六個軸的min、max,筆者這邊六組都傳回0和65535,即使手把沒有6個軸也一樣,好像也是指系統支援到多少而不是目前手把的資訊。想得知手把有哪些軸要看wCaps。

wCaps是一組bit flag,文件裡有寫常數名稱但沒寫它們的值,要查上面說的joystickapi.h。
JOYCAPS_HASZ 0x1
JJOYCAPS_HASR 0x2
JJOYCAPS_HASU 0x4
JJOYCAPS_HASV 0x8
JJOYCAPS_HASPOV 0x10
JJOYCAPS_POV4DIR 0x20
JJOYCAPS_POVCTS 0x40

上面兩個函式的說明文件有個地方寫錯,函式其實不會傳回MMSYSERR_開頭的error code,實際的error code是joystickapi.h裡的以下值。
JOYERR_PARMS 0xa5
JOYERR_NOCANDO 0xa6
JOYERR_UNPLUGGED 0xa7

二、讀取手把輸入
前一篇鍵盤滑鼠輸入是事件驅動,有變化的時候程式才會讀取到資料,手把輸入則是取得目前所有按鈕和軸的狀態。但本程式想做的是有變化的時候才用printf()輸出訊息,想做到這樣要把前一個frame的狀態記錄下來,取得手把狀態時跟之前的狀態比對。

讀取手把狀態的函式是joyGetPosEx(),呼叫前必須先填struct JOYINFOEX的dwSize和dwFlags欄位,呼叫後會把手把狀態存在JOYINFOEX裡面。
MSDN: joyGetPosEx()說明
MSDN: struct JOYINFOEX說明
前面的joyGetPos()也可以取得手把輸入,但是只能支援4個按鈕和三軸。

dwFlags要填想傳回哪些資料,有填的才會傳回來。這裡填JOY_RETURNALL,查看joystickapi.h可以得知它等於以下值位元or:
JOY_RETURNX, JOY_RETURNY, JOY_RETURNZ,
JOY_RETURNR, JOY_RETURNU, JOY_RETURNV,
JOY_RETURNPOV, JOY_RETURNBUTTONS


按鈕狀態會存在joyinfo.dwButtons,每個bit是一個on和off的開關,共32 bits。
解釋一下這邊的程式碼:^是XOR運算,先把prevState->button和joyinfo.dwButtons做XOR運算找出有變化的bits。
然後用一個bitFlag變數,for迴圈每跑一次將它左移1 bit,檢查changedButtons的各個bit,如果是1代表與前一個frame不同。
根據筆者經驗,按鈕一定從低位元開始連續排列,不會有bit跳過,可以拿joyGetDevCaps()取得的按鈕數量作為迴圈次數。

軸存在dwXpos等6個DWORD變數裡,由於這6個變數在記憶體裡相鄰,用指標變數joyinfoAxis指向第一個值就可以當陣列處理。
軸不一定連續排列,6個變數可能中間有些不使用,實際有哪些軸要看joycaps.wCaps。此處是6個值都檢查,只有6個數字效能不會消耗很多。

如上所說,十字鈕或類比桿對應到POV的時候,POV的值是間隔45度的八個方向。雖然類比桿理論上做得到360種角度,筆者以前有一支手把是類比桿對應到POV,它的POV仍然只有8個方向。

dwFlags有兩個flag:JOY_RETURNCENTERED和JOY_RETURNPOVCTS筆者也不知道用途,測試結果是有沒有這兩項傳回的東西都一樣。

最後把目前狀態存在prevState裡,下一個frame跟此時的狀態比對。

用這個指令build
cl joystick.c /Fejoystick.exe /O2 /MD /link winmm.lib
這次沒有GUI所以不用連結user32.lib,但要連結winmm.lib。

執行畫面
左:初始狀態,因為一開始把struct JoystickState設為全部是0,會印出初值不是0的項目。
右:操作手把後的反應。


如果在視窗程式用這個方法會有個現象:視窗非作用中的時候也會收到手把輸入。如果希望視窗非作用時不要有反應,要用WM_SETFOCUS和WM_KILLFOCUS這兩個視窗訊息,以後有機會再介紹。



手把輸入有很多細節說明文件也沒寫,是筆者用過很多個手把實測累積的經驗。

每個手把按鈕配置不一樣,同一編號的按鈕不同手把會在不同位置,要讓玩家可以設定按鈕;也不能假設每個十字鈕都是X、Y軸,要讓玩家可以設定用哪個軸控制方向。

十字鈕雖然是on、off兩個狀態的按鈕但內部資料是0~65535,也可能是視覺頭盔,如何把它當作按鈕處理就看你的程式邏輯能力。

如果目前沒有連接手把,Windows XP呼叫joyGetPos()會停頓一點時間。在主執行緒偵測目前有沒有連接手把會讓程式卡頓,最好分開一個thread做這件事,等找到手把再由主執行緒讀取手把訊息。
Windows 7以後不會停頓,但還是分開一個thread比較保險。

有些情況藍牙手把離線後joyGetPos()和joyGetPosEx()仍然傳回JOYERR_NOERROR,等約20秒才偵測得到手把離線,有線手把沒這個問題。
初步測試是Windows 7和Linux會這樣,Windows 10不會,但Windows 7和Linux是同一台電腦灌兩個作業系統,不確定是因為這台電腦的藍牙硬體,還是因為作業系統。

有些遊戲和模擬器只在起動時偵測手把,不能偵測手把拔掉重插,而有線手把用久了難免有磨損,一旦接觸不良讓作業系統判定手把被拔出,手把就無效了,只能把程式關掉重開。個人覺得這點很討厭,所以自製引擎有做到手把拔掉重插也能繼續玩。



據我所知Windows取得手把輸入有五種方法:
  1. joyGetPosEx(),本篇介紹的方法,功能不是最強但用法簡易。
    上面說到此方法的上限是32個按鈕、8個軸,這對大部分遊戲來說夠用,但是像這個飛行模擬的控制器就不夠用了,據說有104個按鈕。
    https://x-plane.vip/quickmade/qmcp737c/
    下面的raw input和DirectInput也許可以支援更多按鈕,筆者沒研究過。
  2. 視窗訊息。先用joySetCapture()設定再用WndProc()接收訊息。
    MSDN: Joystick Notifications
    只能支援三軸和四個按鈕,基本上很不夠用。
  3. raw input訊息,鍵盤與滑鼠輸入篇也有提到這個方法。
    這個API把鍵盤和滑鼠以外的裝置都稱為HID(Human Interface Device),用相同的函式和struct處理,沒有為手把設計的API,從原始資料解析出有用的資訊要費一番工夫。
    MSDN: Raw Input
  4. DirectInput
    可以支援force feedback(震動之類的功能)。
    MSDN: DirectInput
  5. XInput
    一般手把都會支援1~4,XInput要Xbox 360以後的Xbox手把,或是副廠做的相容手把才有支援。
    筆者沒用過這個API,看說明好像可以支援Xbox專用功能,例如振動、聲音輸入、輸出。
    MSDN: XInput Game Controller APIs

創作回應

更多創作