ソフトウェア編
その3 任意の時間でボタンを押したり離したり
PCでPS2のボタンを押す。それはパラレルポートへ信号を出力することで可能と思われます。しかし、音ゲーの場合は非常にシビアなタイミングでボタンを押さなければなりません。ボタンは最大8つ・・・それぞれを独自のタイミングで押したり離したりする・・・いったいどうやるのか?この章では、PCにジャストタイミングで信号を発せさせる(ボタンを押したり離したりさせる)機構を作ります。
@ ボタン操作オブジェクトで実験
8つのボタンを全部管理するのは大変ですから、ここは1つのボタンに注目します。1つのボタンの自動操作を担当するオペレータを作成しましょう。オペレータが何をするか整理します。
・ ゲームが始まると自分の持っているタイミングリストを見て、次のイベントまで待機
・ 「ボタンを押す」イベントの実行時間になったら、ボタンを押す
・ 「ボタンを離す」イベントの実行時間になったら、ボタンを離す
この仕様から具体的なToDoリストを作成します。
ToDoリスト ・ タイミングリストに「1000ミリ秒後ボタンを押す」を登録し、登録内容「1000ミリ秒後ボタンを押す」を確認 ・ スタートしてから1000ミリ秒後にボタンを押す ・ 時刻800ミリ秒で「ボタンを離す」イベントを発令しボタンを離す ・ 時刻400ミリ秒で「ボタンを押す」、700ミリ秒で「ボタンを離す」、1000ミリ秒で「ボタンを押す」
ToDoリスト1段目のテストはこんな感じでしょうか。
BtnOperatorTest.cpp class CBtnOperatorTest : public TestCase
{
public:
// タイミングリストに「1000ミリ秒ボタンを押す」を登録
void testRegistTimingData()
{
sp<CBtnOperator> spOp(new CBtnOperator); // オペレータ生成
BTNTIMING timing(1000, BTN_PUSH);
spOp->AddList(timing);
assertEquals( 1000, spOp->GetRegistData(0).m_uiTime); // 時刻取得
assertEquals( BTN_PUSH, spOp->GetRegistData(0).m_dwEvent); // イベント取得
}
};
ボタンのタイミング時間とイベントを格納するBTMTIMING構造体にデータを格納し、CBtnOperatorクラスのAddList関数でそれを登録、GetRegistData関数で格納されたBTNTIMINg構造体から時刻とイベントを取得する予定でいます。コンパイルして、エラーを潰していきましょう。
まずは、BTNTIMING構造体を定義します。
BTNTIMING.h #define BTN_PUSH 1
class BTNTIMING
{
public:
unsigned int m_uiTime; // 時刻
DWORD m_dwEvent; // イベント
BTNTIMING(unsigned int time = 0, DWORD event = 0)
{
m_iTime = time;
m_dwEvent = event;
}
};
次に、CBtnOperator本体を作ります。まずAddList関数ではデータの登録を行いますから、メンバ変数にBTNTIMINGの配列m_BtnTmAry変数を設定します。配列の最後尾にタイミング構造体を追加します。
BtnOperator.cpp int CBtnOperator::AddList(BTNTIMING &btntiming)
{
m_BtnTmList.push_back(btntiming);
return m_BtnTmAry.size();
};
GetRegistData関数は、登録した構造体を返すだけです。引数の要素が無ければエラー情報を返してクライアントに伝える形式にします。
BtnOperator.cpp BTNTIMING CBtnOperator::GetTiming(unsigned int elem)
{
// 指定の要素が無ければエラーコードを返す
if(elem > m_BtnTmList.size())
return BTNTIMING(-1, -1);
return m_BtnTmAry[elem];
};
これで、テストはオールグリーンです。
B スタートしてから1000ミリ秒後にボタンを押す
ToDoリスト2段目は「スタートしてから1000ミリ秒後にボタンを押す」です。これをどう実装するか・・・。使う方(クライアント:テスト側)から考えてみます。まず、クライアントが「開始」と命令すると、オペレータはタイマーを始動させる。オペレータは指定の時間1000ミリ秒が経過したら「ボタンを押す」イベントを発生させる。時間を監視しているのはオペレータであり、クライアントはその間何もしません。これは、クライアント側とオペレータ側の2つのつのプロセスが存在しています。つまり、オペレータ用の別スレッドの生成が必要になります。
まずは、クライアント側のテストプログラムを書きます。
BtnOperatorTest.h class CBtnOperatorTest : public TestCase
{
public:
// スタートしてから1000ミリ秒後にボタンを押す
void test1000msecStopAndGo()
{
sp<CBtnOperator> spOp(new CBtnOperator); // オペレータ生成
spOp->InitReady(); // 準備を整える
spOp->Start(); // スタート
// 時間計測
DWORD st = timeGetTime();
while(spOp->GetState().m_dwEvent != BTN_PUSH); // イベント待ち
DWORD ed = timeGetTime();
spOp->Stop(); // 操作一時停止
assertEquals("test1000msecStopAndGo", 1000, ed - st); // 経過時刻をチェック
}
};
CBtnOperatorオブジェクトを生成後、InitReady関数でスレッドスタートの準備をします。Start関数でスレッドを開始します。クライアント側としてはStartした瞬間からきっちり1000ミリ秒にボタンを押すイベントが発生して欲しいわけですから、Start後直ちにイベントを監視します(while文)。BTN_PISHが発生したらループを抜け、Stop関数でスレッドを停止後経過時間をチェックします。
CBtnOperator::InitReady関数は、タイマーの準備をする関数として設けました。スレッドの生成は一瞬とはいえ微妙に時間のかかるものです。よって、スレッドを作成しておいてStart関数を呼び出した瞬間から始める形式の方が正確になります。InitReady関数の実装です。
BtnOperator.cpp BOOL CBtnOperator::InitReady()
{
// スレッドが無ければ準備をする
if(!HaveReadied())
{
// スレッド関数に自分自身を渡す
m_uihThread = _beginthreadex(
NULL, // セキュリティー
0, // スタックサイズ
CBtnOperator::_TimerThreadFunc, // スレッド関数
this, // 自分自身をスレッドに渡す
CREATE_SUSPEND, // サスペンド状態
&m_uiThreadAddr // スレッドアドレス
);
if(m_uihThread == 0)
return FALSE; // スレッド作成失敗
return TRUE; // 作成成功
}
// すでに有効なスレッドがある
return TRUE;
}
関数内で_beginthreadex関数によってスレッドを待機状態で作成しています。第3引数にタイマーを監視する_TimerThreadFuncスレッド関数へのポインタを渡し、第4引数でこのオブジェクトへのポインタ自身(this)を渡しています。InitReady関数は、スレッドの準備が出来ていればTRUEを返します。細かい点ですが、_beginthreadex関数を使って作成したスレッドのハンドルはCloseHandle関数を用いて閉じないとメモリリークを起こします。これはデストラクタの役目でしょうね。
_TimerThreadFuncスレッド関数の内部ではどのようなことをしなければならないか?テストコードを満たすには、スレッド関数内でタイマーが作動してる必要があります。タイマー(ストップウォッチ)は、開始時刻と現在の時刻を見比べればその機能を成します。現在の時刻をミリ秒単位で取得できる便利なtimeGetTime関数を用いて、スレッド関数内の実装をしてみます。
BtnOperator.cpp unsigend int __stdcall CBtnOperator::_TimerThreadFunc(void* me)
{
unsigned int uiDefTime;
unsigned int uiStartTime;
// 引数のポインタをCBtnOperatorとみなす
CBtnOperator *pBtnOp = (CBtnOperator*)me;
// 現在の時刻を取得
uiStartTime = timeGetTime();
while(!(pBtnOp->IsStop()))
{
// 差分時間を計算
uiDefTime = timeGetTime() - uiStartTime;
pBtnOp->SetCurTime(uiDefTime);
pBtnOp->EventProc(uiDefTime); // イベントプロシージャへ時刻を通知
}
return 0; // 正常終了
}
引数はCBtnOperatorオブジェクト自身なので、ポインタを変換します。次に現在の絶対時間を取得しスタートタイムとします。あとは永久ループに突入し、差分時間を計算してはそれをCBtnOperator::SetCurTime関数に渡します。そして、イベントを実際に発動するEventProc関数へ現在の差分時刻を通知します。ここがスレッド関数の最も大切な仕事です。CBtnOperator::IsStop関数はタイマーを止める命令が出ているかをチェックする関数として設定します。
SetCurTime関数およびIsStop関数の実装です。とてもシンプルです。
BtnOperator.cpp void CBtnOperator::SetCurTime(unsigned int time)
{
m_dwCurTime = time;
}
BOOL CBtnOperator::IsStop()
{
return m_bIsStop;
}
根幹のEventProc関数は、引数に渡された時刻を見てボタンを押す、もしくは離すイベントを発生させます。
BtnOperator.cpp void CBtnOperator::EventProc(unsigned int time)
{
// 実行待ちのイベントを取得
BTNTIMING BtnTim = GetNextEvent();
if(time >= BtnTim.m_uiTime)
{
// イベント実行
switch(BtnTim.m_dwEvent)
{
case BTN_PUSH: // ボタンを押す
Push(time);
break;
case BTN_RELEASE: // ボタンを離す
Release(time);
break;
default:
break;
}
// 現在のイベント状態を更新
m_dwCurEvent = BtnTim.m_dwEvent;
// イベントが実行されたら次のイベントへ
UpdateNextEvent();
}
}
最初に次に行う予定のイベントを取得しておきます。その実行時間を引数の経過時間と比較して、経過していたらすべきイベントを実行します。実行後、現在のイベント状況を更新して、実行待ちのイベントを更新します。特段難しいところは無いと思います。GetNextEvent関数内部では登録されているイベント配列を参照しているだけですし、UpdateNextEvent関数は配列の要素番号をインクリメントするだけです。
BtnOperator.cpp // 次のイベントを取得
BTNTIMING CBtnOperator::GetNextEvent()
{
// 次がもう無い場合は終端コードを渡す
if(m_BtnTmAry.size() <= m_iCurEventElem)
return BTNTIMING(0xffffffff, BTN_NOEVENT);
return m_BtnTmAry[m_iCurEventElem];
}
// 次のイベントへ参照を移す
void CBtnOperator::UpdateNextEvent()
{
m_iCurEventElem++;
}
ここまででInitReady関数から派生した諸関数の設定ができました。随分長かったのですが、これで根幹は出来上がっています。テストコードでの次はStart関数です。これは、スレッドが出来ていたらサスペンドをはずしてスレッドをスタートさせればOK。Stop関数は現在のスレッドに停止命令を与えます。それはm_bIsStop変数をTRUEにするだけです。コードは、
BtnOperator.cpp void CBtnOperator::Start()
{
// スレッドを開始する
if(HaveReadied()){
m_bIsStop = FALSE;
ResumeThread((void*)m_uihThread);
return TRUE;
}
// スレッドの準備が出来ていない
return FALSE;
}
BOOL CBtnOperator::IsStop()
{
return m_bIsStop;
}
となります。スタートする時にタイマーループの終了フラグであるm_bIsStopフラグをFALSEにしておかないと、スレッドが始まった瞬間に終わってしまいます。停止中のスレッドはResumeThread関数で再開できます。
だいぶ長くなりましたが、これでテストは動きます。スレッドも動きます。しかし、実際に試してみたところ、レッドシグナルになってしまいました。
デバッグ内容 ■■ test1000msecStopAndGo: 期待値は 1000 でしたが、引数は 1531 となり異なっています。
StartしてからPUSH_BTNイベントが発生するまでに1531ミリ秒もかかってしまいました。もちろんReleaseモードで実験しています。登録時間をいろいろ変えて試してみたところ、おおよそ500ミリ秒くらい遅れが出ています。どうやらこれはスレッド実行に時間がかかっているようです。サスペンド状態を解除しても瞬時にコードが始まると言うわけではなくて、0.5秒くらい始動まで時間がかかっているようなのです。残念ながら、今の実装だとクライアントとの同期がまるで取れません。
はてどうしたものかとしばし考え試行錯誤・・・
解決です。スレッド開始をタイマーの開始にしたためズレが出たのなら、スレッドを生成した後スレッド関数内に一時的にストッパーを入れ、それをはずした瞬間からタイマーを始動させるようにしました。生成→実行→待機(ストッパー)→タイマー始動というプロセスをスレッドに踏ますのです。
まず、スレッドをサスペンド状態ではなくて、いきなり実行させるよう生成します。
BtnOperator.cpp BOOL CBtnOperator::InitReady()
{
// スレッドが無ければ準備をする
if(!HaveReadied())
{
// スレッド関数に自分自身を渡す
m_uihThread = _beginthreadex(
NULL, // セキュリティー
0, // スタックサイズ
CBtnOperator::_EventMngThreadFunc, // スレッド関数
this, // 自分自身をスレッドに渡す
0, // 生成時実行
&m_uiThreadAddr // スレッドアドレス
);
if(m_uihThread == 0)
return FALSE; // スレッド作成失敗
return TRUE; // 作成成功
}
// すでに有効なスレッドがある
return TRUE;
}
そして、スレッド関数内部にストッパーを設けます。
BtnOperator.cpp unsigend int __stdcall CBtnOperator::_EventMngThreadFunc(void* me)
{
unsigned int uiCurTime;
unsigned int uiStartTime;
// 引数のポインタをCBtnOperatorとみなす
CBtnOperator *pBtnOp = (CBtnOperator*)me;
// ストッパー
while(!pBtnOp->_CheckStopper())
Sleep(0);
// ループ開始
// 現在の時刻を取得
uiStartTime = timeGetTime();
while(!(pBtnOp->IsStop()))
{
// 差分時間を計算
uiDefTime = timeGetTime() - uiStartTime;
pBtnOp->SetCurTime(uiDefTime);
pBtnOp->EventProc(uiDefTime); // イベントプロシージャへ時刻を通知
Sleep(0);
}
return 0; // 正常終了
}
CheckStopper関数がTRUEを返すまで、ストッパー部分でスレッドは一端待機します。Sleep(0)が入っています。これは、0ミリ秒休むという意味ではなくて「CPUに他の仕事をさせてあげる」という大切な役目を果たします(これはMSDNに書いてあります)。while文の極限スピードループをさせると、他のスレッドの割り込み時間が極端に短くなってしまい、同期がおかしくなってしまうんです。
ストッパーをはずすのはStart関数です。
BtnOperator.cpp void CBtnOperator::Start()
{
// スレッドを開始する
if(HaveReadied()){
m_bIsStop = FALSE;
m_bStopper = TRUE;
}
これで、Start関数を呼び出した瞬間にタイマーが動き出します。テスト関数でInitReady関数を呼び出した後、スレッド生成までに少しだけ時間がかかるので、Startする前に1000ミリ秒ほど待つことにし、リリースモードでテストを再度実行してみると・・・ほぼ、成功します。極たまにちょっとずれたりする時もありますが、許容できる範囲です。ということで、これで仕様を満たしたと言うことにします。
C 時刻800ミリ秒でボタンを離す
ToDoリスト3段目は800ミリ秒後にボタンを離します。前回までにかなりコードを完成させましたので、このテストは楽々です。
BtnOperatorTest.h class CBtnOperatorTest : public TestCase
{
public:
// 800ミリ秒になったらボタンを離す
void testRelease800msec()
{
sp<CBtnOperator> spOp(new CBtnOperator); // オペレータ生成
// 最初にボタンを押した状態にして
// 800ミリ秒後にボタンを離す
spOp->AddList( BTNTIMING(0, BTN_PUSH) );
spOp->AddList(BTNTIMING(800, BTN_RELEASE));
spOp->InitReady(); // 準備を整える
Sleep(2000); // 2秒ほど寝る
spOp->Start(); // スタート
// 時間計測
DWORD st = timeGetTime();
while(spOp->GetState().m_dwEvent != BTN_RELEASE)
Sleep(0); // イベント待ち
DWORD ed = timeGetTime();
spOp->Stop(); // 停止
// 振る舞いをチェック
assertEquals( 800, ed - st); // 離された時刻を取得
}
};
新規で追加する物はありません。今までのコードでグリーンシグナルになります。
D 時刻400ミリ秒で「ボタンを押す」、700ミリ秒で「ボタンを離す」、1000ミリ秒で「ボタンを押す」
ToDoリスト4段目、最後のテストは複合テストです。テストコードはもう問題ないと思います。実際にテストをしてもても、動作に問題は無いようです。よって、このテストの詳細は割愛します。
E ちょっとだけリファクタリング
InitReady関数からStart関数を呼び出すときに、テストコードではスレッド生成時間を待っていました。これはクライアントの仕事ではないですね。Startを呼ぶときには、もうタイマーはいつでも実行できる状態にしておくのがベストでしょう。その為に、待機ループに入る直前に準備完了の変数m_AlreadyをTRUEにし、InitReady関数はこの変数がTRUEになるまでは抜け出せない仕組みにリファクタリングしましょう。
BtnOperator.cpp unsigend int __stdcall CBtnOperator::_EventMngThreadFunc(void* me)
{
unsigned int uiCurTime;
unsigned int uiStartTime;
// 引数のポインタをCBtnOperatorとみなす
CBtnOperator *pBtnOp = (CBtnOperator*)me;
// 準備完了のサインを出す
pBtnOp->SetReady();
// ストッパー
while(!pBtnOp->_CheckStopper())
Sleep(0);
// ...
}
BtnOperator.cpp BOOL CBtnOperator::InitReady()
{
// スレッドが無ければ準備をする
if(!HaveReadied())
{
// スレッド関数に自分自身を渡す
m_uihThread = _beginthreadex(
NULL, // セキュリティー
0, // スタックサイズ
CBtnOperator::_EventMngThreadFunc, // スレッド関数
this, // 自分自身をスレッドに渡す
0, // 生成時実行
&m_uiThreadAddr // スレッドアドレス
);
if(m_uihThread == 0)
return FALSE; // スレッド作成失敗
// 始動開始状態まで待機
while(!m_Already)
Sleep(0);
return TRUE; // 作成成功
}
// すでに有効なスレッドがある
return TRUE;
}
これで、クライアントはスレッド生成時間を気にする必要がなくなります。しかも、リファクタリング後にちょっとしたズレがまず出なくなってしまいました。どうやら、ズレはスレッド生成時の微妙なタイミングを待ちきれていなかった点にあったようです。
別のリファクタリングです。CBtnOperatorクラスは、自動操縦の最小部品としてすでに立派に成り立っています。しかし、Push関数とRelease関数では特に動作定義をしていません。ボタンを押す、離すという動作は、色々な所に活用できますから、これら関数は仮想関数として定義した方が再利用性が高くなります。
EventProc関数も仮想関数にして良いかもしれません。不必要なイベントは実行すらされない仕組みにしてしまえば、バグも減りますね。
E timeGetTime関数の呼び出し時の不安定
ここはちょっと余談です。
VC++で時間を取得する関数は沢山あります。その中で比較的精度の良いタイマーがtimeGetTime関数です。これは、OSが起動してから1msec単位での経過時間をDWORDで取得出来ます。ただ、その精度はOS依存で、NT系(Windows
2000/XPなど)ではデフォルトで低く設定されています(MSDNには5msecよりもっと悪いと書いてあります)。精度を最高まで上げるにはtimeBeginPeriod関数で1ミリ秒に合わせます。
ただ、ちょっと気になること。次のプログラムをご覧ください。
int main(int argc, char* argv[])
{
// 時間差を格納
timeBeginPeriod(1);
DWORD starttime = timeGetTime();
vector<int> str;
for(int i=0; i<1000000; i++){
DWORD time = timeGetTime(); // 現在の時間を取得
str.push_back(time - starttime); // スタートからの経過時間を格納
}
// ファイルに出力
ofstream ofs;
ofs.open("DEBUG.txt");
for(i=0; i<100000; i+=10)
ofs << i << ":\t" << str[i] << endl;
return 0;
}
timeGetTime関数で経過時間を取得して、その経過時間を配列strに100万回も格納。結果を10飛ばしで出力します。もし、timeGetTimeが1ミリ秒を取ってきてくれるのであれば、strには1ミリ秒単位の数値がもれなく含まれているはずです。ところがですね、結果をご覧ください。
0: 30640: 37650: 50830: 64500: 73690: 87240: ... |
0 10 20 21 22 23 24 ... |
30640番目まではずっと0、次がいきなり10ミリ秒となっています。次が20ミリ秒、そこからは1ミリ秒単位で取得できています。これは面白い現象です。最初の数万回の読み出しで経過時刻を正しく取ってくれていません。理由は定かではありませんが、呼び出し直後には何らかの理由でタイム取得の割り込みが効かないのかもしれません。
登録された情報を元にスイッチを独自に押したり離したりするオブジェクトがついに完成しました。正直長くてしんどい実装でした(T_T)。スレッド内部でストッパーを設けることで、同期が飛躍的に向上しました。さて次は、8つのボタンを押したり離したりして、その情報を8bitの数値として出力する仕組みを作ります。これは、これまでのクラス(CTecnicContクラス及びCBtnOperatorクラス)を合わせれば簡単です。