iPhoneデバイスの方向入力インタフェースについて考える

iPhoneバイスにおける方向入力の歴史


App StoreからゲームをダウンロードしてiPhoneiPod touchの上で遊べるようになってから久しいですが、その間iPhoneバイスにおける方向入力の方法は様々なものが考案されてきました。
今日は、その歴史(と言ってもiPhoneを購入して1年未満。大してiPhoneでゲームをしない私の知っている範囲なので薄いですが)を振り返りつつ、最も良さそうなものを自作アプリに導入してみます。

ボンバーマンTOUCH



往年の名作ボンバーマンiOS版。2008年の7月にリリースされました。ボンバーマンといえば上下左右の十字の動きですが、このゲームでは移動したい方向に左下にあるボンバーマンをスライドさせることで移動します(きっと。恐らく。多分そうでした・・・)。友人のiPhoneに入っていたのでやらせてもらったことがあるのですが、かなり操作し辛かった記憶があります。
このゲームの方向入力インタフェースは、据え置きゲーム機や携帯ゲーム気の十字キーに近いものです。しかし、十字キーは例えば上方向から右方向に入力を切り替える時に、十字キーの中心を軸にして指を移動させることで指を離さずに入力を切り替えること、所謂ドラッグができますが、当時の「擬似十字キー」を扱ったゲームではこういったことはできませんでした(maybe)。

ストリートファイターIV for iPhone/iPod touch



往年の(ryストリートファイターの(ry。2010年の3月にリリースされました。ただでさえ難しい複雑なコマンド入力を要求される格闘ゲームをどうやってiPhoneでプレイするのよJKと思っていたらとても画期的な方向入力インタフェースが用意されていました。
画面にはゲームセンターの筐体にあるレバーが表示されており、これをドラッグすることによって滑らかな方向入力が可能になりました。また、入力値はデジタルではなくアナログなのでより細かい操作をすることができます。ボンバーマンのインタフェースが十字キーに似ていたのに対して、ストIVは任:3Dスティック/GK:アナログスティックに似ています。
今年の3月にこのゲームをプレイするまでiPhone上で本格的なゲームをやってこなかったのでボンバーマンからの進化に感動しました。
※ちなみに先ほどのボンバーマンも現在バージョン2.5になり、同様の方向入力インタフェースを採用しています。

FINAL FANTASY III



往n(ryFF3(ry。2011年の3月にリリースされました。リサイクル品です。FF3と言えばフィールド音楽の悠久の風がいいですね。iPhone/iPod touch版が1,800円、iPad版が2,000円というとんでもなく高いゲームです。
実際にiPhoneでプレイしたことはないのですが、ストIVのレバーに感動していた私にid:halwhiteが「FF3の方がすごいよ!」と教えてくれました。
このゲームではストIVのアナログスティックを更に進化させ、好きなところにアナログスティックを出現させることができます(らしいです)。なるほど素晴らしいアイディアだと思いました。さすが公式HPで「これまでのスクウェア・エニックスiPhoneRPGタイトルのノウハウをいかし、直感的でプレイしやすい操作性を実現しています。」と公言するだけあります。ただのリサイクルではないのです。

FlexibleLeverView


ここまで見てきて一番素晴らしかったFINAL FANTASY IIIの拡張アナログスティックを自作アプリに導入してみます。どこにでも出現させることのできる柔軟性からFlexibleLeverと名づけ、このレバーを表示させることができるViewをFlexibleLeverViewとしました。

サンプル


どこかで見た戦車を操作するサンプル。

FlexibleLeverViewは滅びぬ、何処へでもあらわれるさ。

仕様

  • ビューのどこかをタッチするとレバーが現れる
  • 指を離すとレバーが消える
  • レバーが現れている間はドラッグすることでレバーを操作できる
  • レバーは土台を中心に一定距離しか移動できない(可動範囲は土台を中心とした円の内部になる)
  • FlexibleLeverViewDelegateプロトコルはrequiredメソッド- leverInclined:を持ち、これはデリゲートにレバーの傾きを通知するものである
  • FlexibleLeverViewはインスタンス変数id delegate_を持つ

実装


これは非常に残念なのですが、マルチタッチイベントを受信するメソッドに「移動はしていないがタッチされ続けている」ことを受信するものはありません(「移動している」ことを受信する- touchesMoved:withEvent:メソッドはあります)。そのため、開発者が自分でインスタンス変数(e.g. BOOL beingTouched_)を用意して、- touchesBegan:withEvent:メソッドでこれをYESに、- touchesEnded:withEvent:メソッドでこれをNOにすることで「移動はしていないがタッチされ続けている」ことを知る手段を確保する必要があります。
なぜこんなことをするのかというと、アナログスティックの操作ではスティックが少しでも傾いていれば、移動していなくても入力されていることになるからです。本実装では指定イニシャライザである- initWithFrame:メソッドの中でNSTimerによって- notifyメソッドを定期的に呼び出すようにしています。- notifyメソッドではif (beingTouched)という条件で- leverInclined:メソッドを呼び出すことで、デリゲートにレバーの入力値を通知しています。

FlexibleLeverViewのインタフェース

@interface FlexibleLeverView : UIView {
 @private
  BOOL beingTouched_;
  CGPoint inputVector_;
  CGPoint baseLocation_;
  UIImageView *baseImageView_;  
  UIImageView *ballImageView_;
  id<FlexibleLeverViewDelegate> delegate_;
}
@property (nonatomic, assign, readonly) CGPoint inputVector;
@property (nonatomic, assign) id<FlexibleLeverViewDelegate> delegate;

@end

inputVector_はレバーの土台からの入力量と向きを表すためのベクトルです。デリゲートでは- leverInclined:メソッドの中でこの値を読むことによって入力値を得、適当な処理をします。
baseLocation_は土台の位置を表します。
baseImageView_とballImageView_はそれぞれレバーの土台の画像とレバーの球体(?)の画像を扱うビューです。
FlexibleLeverViewはデリゲートメソッド- (void)leverInclined:(FlexibleLeverView *)flexibleLeverViewで自身をデリゲートに渡すので、勝手にインスタンス変数が変更されないようにinputVector_以外は公開せず、inputVector_もreadonlyにしてあります。

FlexibleLeverViewDelegateプロトコル

@class FlexibleLeverView;
@protocol FlexibleLeverViewDelegate
- (void)leverInclined:(FlexibleLeverView *)flexibleLeverView;
@end

- touchesBegan:withEvent

タッチ開始を処理する- touchesBegan:withEvent:メソッドではbeingTouched_をYESにしてタッチされ続けている状態にし、タッチ開始場所を土台の位置としてセットします。また、土台の画像と球体の画像を表示させます。

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
  UITouch *touch = [touches anyObject];
  CGPoint location = [touch locationInView:self];

  // Set |beingTouched_| |YES|
  beingTouched_ = YES;

  // Set |baseLocation_|
  baseLocation_ = location;
  
  // Add |baseImageView_| to self
  baseImageView_.layer.position = CGPointMake(location.x, location.y);
  [self addSubview:baseImageView_];
  
  // Add |ballImageView_| to self
  ballImageView_.layer.position = CGPointMake(location.x, location.y);
  [self addSubview:ballImageView_];
}

- touchesMoved:withEvent:

ドラッグを処理する- touchesMoved:withEvent:メソッドでは土台からタッチしている位置へのベクトル(入力ベクトル)を計算します。この入力ベクトルの長さがレバーの可動範囲(土台を中心とした半径kValidLengthの円の内部)に収まっているかどうか調べます。収まっていない場合は、入力ベクトルの単位ベクトルを求めてkValidLength倍することで円周上を指すようにします。最後に土台へのベクトルと入力ベクトルを合成したベクトルの指す位置に球体を移動します。こうすることでレバーの可動範囲外までドラッグしても、それにつられてレバーの頭がもげてしまうことはありません。
また、メソッドの最後でinputVector_の値を正規化しています。これにより、レバーからの入力値は(x, y) (-1.0<=x, y<=1.0)というベクトルでデリゲートに通知されます。

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
  UITouch *touch = [touches anyObject];
  CGPoint location = [touch locationInView:self];

  inputVector_ = CGPointMake(location.x - baseLocation_.x,
                             location.y - baseLocation_.y);
  CGFloat length = sqrtf(powf(inputVector_.x, 2) + powf(inputVector_.y, 2));
  if (length > kValidLength) {
    // Fit length of |vector| to |kValidLength|
    inputVector_.x = (inputVector_.x / length) * kValidLength;
    inputVector_.y = (inputVector_.y / length) * kValidLength;
  }
  ballImageView_.layer.position = CGPointMake(baseLocation_.x + inputVector_.x,
                                              baseLocation_.y + inputVector_.y);
  
  // Normalize |inputVector|
  inputVector_.x /= kValidLength;
  inputVector_.y /= kValidLength;
}

- touchesEnded:withEvent:

タッチが終了したことを処理する- touchesEnded:withEvent:メソッドではbeingTouched_をNOにしてタッチされていない状態にし、土台の画像と球体の画像を消します。

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
  // Set |beingTouched_| |NO|
  beingTouched_ = NO;
  
  // Remove |baseImageView_| and |ballImageView_| from superview
  [baseImageView_ removeFromSuperview];
  [ballImageView_ removeFromSuperview];
}

- leverInclined:

最後にデリゲートで実装する- leverInclined:です。

- (void)leverInclined:(FlexibleLeverView *)flexibleLeverView {
  CGPoint vector = flexibleLeverView.inputVector;
  
  NSLog(@"レバーを (%f, %f) 方向に入力中", vector.x, vector.y);

  // Move tank
  CGFloat dx = vector.x * kMaxTankVelocity;
  CGFloat dy = vector.y * kMaxTankVelocity;
  tankImageView_.layer.position = CGPointMake(tankImageView_.layer.position.x + dx,
                                              tankImageView_.layer.position.y + dy);
  
  // Rotate tank
  CGFloat angle = atan(vector.y / vector.x);
  if (vector.x < 0.0f) angle += M_PI;
  tankImageView_.transform = CGAffineTransformMakeRotation(angle);
}

戦車をレバーが傾いている方向に移動させます。移動量として(入力値)×(戦車の速さの最大値)を計算することでレバーの傾きに応じて速さが変化します。こういったアナログな値を扱いやすくするために、レバーの入力値を正規化しました。また、進行方向に向かって戦車を回転させています。
複数のFlexibleLeverViewを配置したい場合(そんなことあるだろうか)は、- leverInclined:メソッドの中でif (flexibleLeverView == hogeFlexibleLeverView)などとして処理を分けてあげれば良いです。

[追記] 超信地旋回



戦車が超信地旋回をできるようになりました。
オレンジの領域が前進後退をするレバーのためのビューで、ブルーの領域が超信地旋回をするレバーのためのビューになっています。オレンジのレバーを上に入力すると前進、下に入力すると後退、ブルーのレバーを左に入力すると反時計回りに超信地旋回、右に入力すると時計回りに超信地旋回します。

- (void)leverInclined:(FlexibleLeverView *)flexibleLeverView {
  CGPoint vector = flexibleLeverView.inputVector;
  if (flexibleLeverView == leftLeverView_) {
    CGFloat dx = -vector.y * kMaxTankVelocity * cosf(tankDirection_);
    CGFloat dy = -vector.y * kMaxTankVelocity * sinf(tankDirection_);
    tankImageView_.layer.position = CGPointMake(tankImageView_.layer.position.x + dx,
                                                tankImageView_.layer.position.y + dy);
  } else if (flexibleLeverView == rightLeverView_) {
    tankDirection_ += vector.x * kMaxTankAngleRate;
    if (tankDirection_ < 0.0f) {
      tankDirection_ += (2.0f * M_PI);
    } else if (tankDirection_ > (2.0f * M_PI)) {
      tankDirection_ -= (2.0f * M_PI);
    }
    tankImageView_.transform = CGAffineTransformMakeRotation(tankDirection_);
  }
}

本当は左のレバーで左の履帯を、右のレバーで右の履帯を動かすようにした方が本物感が増すのですが、面倒なので実装していません。去年のICPCアジア地区予選の模擬練習会でこんな問題あったなぁw