暑い日が続いていますが、いかがお過ごしですか。最近複数の人から、「老けた」と言われて若干凹んだponpoko1968です。 「iPhoneをアナログゲームコントローラにしてみる」の最終回、サーバの実装です。 まずはデモ動画からどうぞ。


今回は、前回作成したリモートコントローラの状態を受信して、リアルタイムで表示するサンプルアプリを作成します。

アプリケーションの作成

先ずはプロジェクトの作成です。サーバについても、簡素な画面構成なので、「View-Based Application」を選択します。 主に手を加えるのは、メインの画面を制御するViewControllerクラスです。

Game Kitサーバのセットアップ

ViewControllerがGame Kitからの通知を受信できるよう、GKSessionDelegateプロトコルをサポートさせます。

@interface AnalogRemoteServerViewController : UIViewController {
  // Game Kit
  GKSession* gkSession;
  NSMutableDictionary* peers;
(中略)
「UIViewController」のように継承元のクラスの指定の後ろに、"<",">"で括ってサポートするプロトコルを指定します。複数指定する場合はカンマで区切って書きます。 ViewControllerクラスに、接続しているコントローラの状態を保持するメンバ変数を追加します。 リモートコントローラ同様、サーバもGKSessionインスタンスへのポインタを保持します。 さらに、サーバは複数のリモートコントローラからの接続を考慮し、各リモートコントローラの状態を保持する辞書を追加します。 サーバはリモートコントローラからの接続時にPeerIDと呼ばれる文字列を渡されるので、この文字列をキー値として、後述するControllerStateクラスのインスタンスを格納することにします。

クライアントからの接続を受け付ける

リモートコントローラがサーバに接続しに来ると、didReceiveConnectionRequestFromPeerが呼ばれます。

- (void)session:(GKSession *)session didReceiveConnectionRequestFromPeer:(NSString *)peerID {
  NSError* error;
  NSLog(@"session:%p didReceiveConnectionRequestFromPeer: from=\"%@\"", session, peerID );
  if(  [peers count] < 1 ){
    [session acceptConnectionFromPeer:Peerid error:&error];
    [session setDataReceiveHandler:self  withContext:peerID];
  }else {
    [session denyConnectionFromPeer:peerID ];
  }
}
今回は、サンプルプログラムの簡単のため、リモートコントローラからの接続を1台のみに制限しています。 具体的にはpeersメンバ変数の要素数が1未満の場合には、セッションにacceptConnectionFromPeerを呼んで接続を許可します。 また、1以上の場合、すなわちリモートコントローラがすでに接続している場合はdenyConnectionFromPeerを呼んでそれ以上の接続を拒否します。 今回のコードも設計上は複数のリモートコントローラからの接続をサポートしているので、ゲームのデザインによって同時に接続できるリモートコントローラのの最大数を変更すると良いでしょう。 このとき、sessionに対し、

    [session setDataReceiveHandler:self  withContext:peerID];
を呼び出すことで、データが到着したときのハンドラ(コールバック)関数をViewControllerに設定します。ハンドラ関数は名前と引数の型が決められており、セッションからデータを受け取るクラスは

- (void) receiveData:(NSData *)data fromPeer:(NSString *)peer inSession: (GKSession *)session context:(void *)context 
の形式の関数を定義しなければいけません。 このあと、通常であれば、session:peer:didChangeStateが呼ばれます。 アプリケーションがリモートコントローラからの接続が確立したと認識させるのは、このsession:peer:didChangeState:が呼ばれたタイミングで、peerすなわちリモートコントローラの状態を見て行いましょう。以下のコードを参照して下さい。

- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state {
  NSLog(@"session:%p peer:%p\"%@\" didChangeState:%d",session,peerID,peerID,state );
  if( state == GKPeerStateConnected ){
    ControllerState* controller = [[ControllerState alloc] init];
    controller.delegate = self;
    [peers setObject:controller forKey:peerID];
    [controller release];

    NSLog(@"state = GKPeerStateConnected");


  }else if ( state == GKPeerStateDisconnected ){
    NSLog(@"state = GKPeerStateDisconnected");
    [peers removeObjectForKey:peerID];
  }

}
上記メソッドが呼ばれる際、GameKit側からGKPeerConnectionState定数が渡されます。stateの値がGKPeerStateConnectedであった場合には、peerIDをキーに、後述するControllerStateインスタンスを生成してpeersメンバ変数に登録しておきます。 その後、

    controller.delegate = self;
として、 ControllerStateクラスがコントローラの状態変化を検知した場合に、ViewControllerクラスが通知を受け取るようにします。

データの受信

前節で説明したように、Game Kitが受信したデータをビューコントローラクラスに定義したハンドラ関数で処理します。今回はControllerStateという独自のクラスを定義し、受け取ったデータの解釈は、そのクラスにまかせることにします。

- (void) receiveData:(NSData *)data fromPeer:(NSString *)peer inSession: (GKSession *)session context:(void *)context {
  ControllerState* controller = [peers objectForKey:peer];
  if( controller ) {
    [controller updateWithDecodingRawData:data];
  }else {
    NSLog(@"data from unknown peer %@",peer);
  }
}
Game Kitから渡されたpeer文字列をキーに ControllerStateインスタンスの参照を取り出し、引数で受けとったNSDataのインスタンスを渡します。

データのデコード

今回のサンプルでは、ちょっと大げさですが、リモートコントローラから送信されたコントローラの状態を解釈するクラスControllerStateと、その状態を他のクラスに通知するためのデリゲート通知プロトコルControllerStateDelegateを定義しました。 こういったクラスとプロトコルを定義した意図として、通信セッションの接続や切断などに関するあれこれはシステム内で単一であることが保証されているクラス(=シングルトン)に担当させるのが適切ですが、ゲーム内の複数のキャラクタを各リモートコントローラを使って動かしたい場合などには、設計上各リモートコントローラからの情報を各キャラクタを表現するクラスが直接受け取ることで、プログラムの見通しが良くなると考えたからです。 また、デリゲートプロトコルを介することで、たとえば、ユーザからの入力をリモートコントローラから外付けキーボードなど別の周辺機器に切り替えたい場合にゲーム側のロジックに関するコードを変更する必要がなくなります。
今回の場合GKSessionのデリゲートを受け取るのはViewControllerなので、CoCoaの設計上は厳密に言うとシングルトンではないのですが、アプリケーション構成上Viewが1つしかないためViewControllerに色々やらせてます^^;
一連の処理をまとめると、下記の図のようになります。 データの流れ

画面表示

最後に、コントローラの状態表示を行います。今回の場合はControllerStateクラスが検知したリモートコントローラの状態変化もViewControllerが受け取ります。 ControllerStateクラスは下記の3つの通知を送ります。
  • buttonPressed - ボタンが押された
  • buttonReleased - ボタンが離された
  • controllerTilted: - コントローラが傾いた
buttonPressed、 buttonReleasedについては比較的自明なのでサンプルコードを見て頂くとして、 controllerTiltedについては、

- (void) controllerTilted:(float)tilt {
  //NSLog(@"controllerTilted val = %f",tilt);
  [arrowView setTransform:CGAffineTransformMakeRotation(tilt)];
}
としています。tiltという値はコントローラの傾き角度がラジアンで渡ってきているので、矢印の画像を持つサブビューをInterfaceBuilderで作り、そのビューに対してCGAffineTransformMakeRotation()で作成した回転行列を作用させることで、矢印の画像を回転させています。

最後に

最終回となりましたこの連載ですが、こうして見直すといくつか課題が残っています。
  • UDPなので、パケットの順番が逆転する可能性があるが対応されていない
  • →UDPベースの既存のプロトコルを参考にしてみる
  • ControllerStateを介することでパフォーマンスに影響はないか?あるとしたらどのように改良すべきか
  • →Objective-Cのメソッド呼び出しを高速化する方法を調べてみる
  • ボタンを押したときの衝撃でiPhoneが揺れてしまう
  • →n時点前からのiPhoneの傾きの移動平均値を計算して傾きを緩やかにする(ゲームによるけど)
といったところが挙げられます。筆者なりにそれぞれの対応案もあるのですが、もし本稿を読まれて自分のアプリに取り入れてやろうという方がいらっしゃったら、是非このような課題にもチャレンジしていただいて、さらにそのような改良をブログ等で発表して頂ければうれしいです。 サンプルコードはこちらからどうぞ。