Help us understand the problem. What is going on with this article?

スマホでもゲームパッド感覚で遊びたい?よろしい、ならばVirtualGamePadだ!(十字キー編)

More than 1 year has passed since last update.

この記事は Ebiten Advent Calendar 2018 5日目の記事です。
Advent Calendar初参加でドッキドキですが、張り切っていきたいと思います!!ʕ◔ϖ◔ʔ

ゲームパッドが好き

こんにちは!ファミコン世代の@kemokemoと申します!
最近息子たちもゲームパッドに慣れてきて、我が家の「ニンテンドークラシックミニ」と「ニンテンドークラシックミニ スーパーファミコン」の稼働率はかなり高めです。

突然ですが、私、ゲームパッドの操作性でゲームをするのが好きなんです。
スマホやタブレット用のゲームでも、ゲームパッドっぽいのが画面上に表示されて方向キートリガーボタン(押したら技とか出るやつ)をグリグリ、バシバシ操作して遊ぶようなものが結構ありますよね?ああいう感じです。

この記事について

上記で挙げたような画面上に表示された擬似的なゲームパッドのことを、自分は便宜上VirtualGamePadと呼んでおりまして、少し前にebitenを使って実装してみました。

https://github.com/kemokemo/ebiten-sketchbook/tree/master/virtual-gamepad

この記事では特に十字キーにスポットを当ててお話したいと思います。
最後までお付き合いいただけますと幸いです!ʕ◔ϖ◔ʔ

前説

「Go言語もebitenライブラリも知ってるよ!」という方は読み飛ばして実現イメージの確認へと進んでください。

Go言語はゲームだって作れます

日々、多くのオープンソースや企業のプロダクトで採用されているGo言語、その魅力については敢えて語るまでもないかと思います。簡潔に分かりやすく書けてパフォーマンスも最高、そんなGo言語でゲームだって作れます。そう、@hajimehoshi さんが作っておられる ebitenライブラリを使えばね :smile:

準備

この記事では、ebitenを使ったゲーム制作の始め方的な内容は割愛いたします。
その代わりと言ってはなんですが、参考情報を以下に書きます。

開発環境

開発環境の整え方について公式WikiのPlatformsにプラットフォーム毎の内容がありますので、そちらをご覧になってください。Windows、Ubuntu Desktop、macOSなど、どこでもサクッと始められます。実際にやってみたことある私が保証しますʕ◔ϖ◔ʔ

チュートリアル

ebitenに興味出てきた、簡単な例を実際に動かしてみたい」という方には公式WikiのTutorialsがオススメです。「チュートリアルも終わってもっといろいろ動かしてみたいぞ」という場合には公式リポジトリのexamplesフォルダにあるたくさんのサンプルが最高の教材になると思います。

実現イメージの確認

改めまして、今回ご紹介するVirtualGamePadを使ったサンプルの画面は以下です。
動かしてみた動画はYouTubeにアップしたので こちら からご覧いただけます!
image.png
主な特徴は以下のような感じです。

  • 画面上の十字キーを指でグリグリ押したら、キャラクターがグリグリ動く
  • Aボタンを指で押して離したら、キャラクターがモードチェンジ(色が変わって、ショットの種類も変わる)する
  • Bボタンを指で押している間、ショットが出続ける

こうやって改めて見ると、そこはかとなくゲームウォッチっぽいですね(;´∀`)

十字キーの実現方法について説明しますよ!

directional_pad.png

このように画面上に十字キーを表示して 単純にタッチ位置と方向キー画像の当たり判定だけ を行うと、以下のような問題が起きました。

  • 上と下同時押し といった、現実世界のゲームパッドでは発生しないような入力ができてしまう
    • ユーザーの入力方向をどっちやと判定すればよいのか・・
  • 押してるつもりなんだけど反応しない、反応がシビアすぎ

改めて考えてみると、ファミコンのゲームパッドとかって上下や左右は同時に押せないようになってるんですよね。以下はWikipediaの十字キーの記事からの引用です。

十字の中心には支点があり、カバーがシーソーのように動くことで、上と下または左と右のスイッチが同時に押せないようになっている。十字キーを斜めに押すことで上と右、下と左のように隣り合う2つのスイッチを同時に押せるようになっており、これにより8方向の入力が可能になっている。

また、現実の十字キーは方向キーの真ん中に指が当たっていなくても 方向キーの端っこに指がかかっているだけで入力状態になります 。上記の説明にあるような機構のため、方向キーの端っこを押しただけでもキーパッド全体が傾いて入力として受け付けてくれるからです。

これらの特徴を実現することで、より現実のゲームパッドに近い操作感が得られそうです。

考え方と作り方

上記に挙げたような現実のゲームパッドの機構を考慮しながら、よりリアリティが感じられるVirtualGamePadの作成に取り掛かりましょう。

まず上記で挙げた問題点を解決すべく、以下のような対策案を考えました。

  • ユーザー入力と複数の方向キーの当たり判定結果から、総合的に「入力方向」を確定する仕組みを作る
  • 動かせるモックをいじりながら、タッチ入力に対する方向キーの当たり判定範囲をいい感じに広げる

この考えを実現しつつ、以下のような作り方を実践しました。

  • 上下左右に4つ配置した方向キーを管理するDirectionalPadを作る
  • 外部からもらった座標を元に、ゲームパッドをいい感じに配置する
  • ebitenから毎フレーム呼ばれるUpdateメソッドで以下の処理を行う
    • ユーザーのタッチ位置から各方向キーの押下状態を更新する
      • 各方向キーの押下状態から総合的に判断してユーザー入力方向を判定する
    • 押される方向キーdirectionalButtonに「君、押されてるで」と教える
  • 方向キーの当たり判定範囲をいい感じに調整する

上下左右に4つ配置した方向キーを管理するDirectionalPadを作る

NewDirectionalPad

directional_pad.go
// NewDirectionalPad returns a new DirectionalPad.
func NewDirectionalPad(pad, button *ebiten.Image) (*DirectionalPad, error) {
    dp := &DirectionalPad{
        baseImg: pad,
        op:      &ebiten.DrawImageOptions{},
    }

    err := dp.createButtons(button)
    if err != nil {
        return nil, err
    }
    return dp, nil
}

func (dp *DirectionalPad) createButtons(img *ebiten.Image) error {
    if dp.buttons == nil {
        dp.buttons = make(map[Direction]*directionalButton, 4)
    }

    ds := []Direction{Left, Upper, Right, Lower}
    for _, direc := range ds {
        b, err := newDirectionalButton(img, direc)
        if err != nil {
            return err
        }
        dp.buttons[direc] = b
    }

    return nil
}

引数でゲームパッドの土台とする画像padと各方向キーにしたい画像buttonを渡すと、いい感じの*DirectionalPadを返すメソッドです。土台にする画像サイズと方向キー画像サイズをあらかじめ調整しておけば、下図のようにゲームパッドの見た目を調整できます。

image.png

外部からもらった座標を元に、ゲームパッドをいい感じに配置する

SetLocation

directional_pad.go
// SetLocation sets the location to draw this directional pad.
func (dp *DirectionalPad) SetLocation(x, y int) {
    dp.op.GeoM.Translate(float64(x), float64(y))

    wp, _ := dp.baseImg.Size()
    halfWp := int(wp / 2)
    wb, _ := dp.buttons[Left].Size()
    halfWb := int(wb / 2)
    outerMargin := int(0.25 * float64(halfWp))
    innerMargin := int(0.08 * float64(halfWp))

    dp.buttons[Left].SetLocation(x+outerMargin, y+halfWp-halfWb)
    dp.buttons[Upper].SetLocation(x+halfWp-halfWb, y+outerMargin)
    dp.buttons[Right].SetLocation(x+halfWp+innerMargin, y+halfWp-halfWb)
    dp.buttons[Lower].SetLocation(x+halfWp-halfWb, y+halfWp+innerMargin)
}

マージンを設定しながら四つ葉のクローバーのように方向キーを配置します。

ebitenから毎フレーム呼ばれるUpdateメソッドで内部ステータスの更新をする

Update

directional_pad.go
// Update updates the internal status of this struct.
func (dp *DirectionalPad) Update() error {
    dp.updateDirection()
    dp.updateButtons()

    return nil
}

updateDirectionでユーザーが押した方向を判定して、updateButtonsで方向キーにその情報を伝えてます。順に見ていきます。

ユーザーのタッチ位置から各方向キーの押下状態を更新する

updateDirection

updateDirectionメソッドでの処理を見てみましょう。

directional_pad.go
func (dp *DirectionalPad) updateDirection() {
    IDs := ebiten.TouchIDs()

    // There are no touches
    if len(IDs) == 0 {
        dp.selectedDirection = None
        return
    }
...

まずebiten.TouchIDs()でユーザーのタッチ情報を得るためのIDリストを取得します。ユーザーのタッチがない場合にはnilが返ってきますので、その場合にはlen(IDs)が0になって「ユーザーはタップしてない、選択してる方向は無し」として処理を終えます。(なんで IDs == nil で判定しなかったんや俺・・(´・ω・`)

directional_pad.go
func (dp *DirectionalPad) updateDirection() {
...
    directions := []Direction{}
    // Find the newly touched direction key.
    sort.Slice(IDs, func(i, j int) bool {
        return IDs[i] < IDs[j]
    })
    for index := range IDs {
        for key := range dp.buttons {
            if isTouched(IDs[index], dp.buttons[key].GetRectangle()) {
                directions = append(directions, key)
            }
        }
    }
    dp.defineDirection(directions)
}

sort.Sliceを使ってタッチのIDsを小さい数字順に並べて「先にタッチされたものから順に評価」できるようにしています。isTouchedでタッチした座標が各ボタンの表示領域に含まれているかを評価して、含まれていればその方向を「ユーザーが今のフレームで押している方向」としてdirections配列に追加しています。

このdirectionsを後述のdefineDirectionに渡して一意の方向にします。

各方向キーの押下状態から総合的に判断してユーザー入力方向を判定する

defineDirection

directional_pad.go
func (dp *DirectionalPad) defineDirection(directions []Direction) {
    current := None
    for index := range directions {
        current = getMergedDirection(current, directions[index])
    }
    dp.selectedDirection = current
}

directionsにはより優先度が高いユーザー入力から順に保存されているので、これを順番に取り出して評価していきます。

getMergedDirection

direction.go
func getMergedDirection(previous, current Direction) Direction {
    switch previous {
    case Left:
        if current == Upper {
            return UpperLeft
        } else if current == Lower {
            return LowerLeft
        } else {
            return previous
        }
    case Upper:
        if current == Left {
            return UpperLeft
        } else if current == Right {
            return UpperRight
        } else {
            return previous
        }
    case Right:
        if current == Upper {
            return UpperRight
        } else if current == Lower {
            return LowerRight
        } else {
            return previous
        }
    case Lower:
        if current == Right {
            return LowerRight
        } else if current == Left {
            return LowerLeft
        } else {
            return previous
        }
    default:
        return current
    }
}

評価方法は至って簡単、以下のとおりです。案外これでいい感じになります。

  • より優先度の高い入力previousに入ってる
  • 現在評価対象となっている方向currentが、previousに隣接するなら合成する
    • previousLowercurrentRightなら、LowerRightを返す
  • previousが合成済みのUpperLeftなどであれば何もしない

押される方向キーdirectionalButtonに「君、押されてるで」と教える

updateButtons

directional_pad.go
func (dp *DirectionalPad) updateButtons() {
    for key := range dp.buttons {
        dp.buttons[key].SelectButton(false)
    }
    if dp.selectedDirection == None {
        return
    }

    if dp.selectedDirection == UpperLeft {
        dp.buttons[Upper].SelectButton(true)
        dp.buttons[Left].SelectButton(true)
    } else if dp.selectedDirection == UpperRight {
        dp.buttons[Upper].SelectButton(true)
        dp.buttons[Right].SelectButton(true)
    } else if dp.selectedDirection == LowerLeft {
        dp.buttons[Lower].SelectButton(true)
        dp.buttons[Left].SelectButton(true)
    } else if dp.selectedDirection == LowerRight {
        dp.buttons[Lower].SelectButton(true)
        dp.buttons[Right].SelectButton(true)
    } else {
        dp.buttons[dp.selectedDirection].SelectButton(true)
    }
}

決定したユーザー入力方向selectedDirectionに従って、各方向キーであるdirectionalButtonの選択状態を更新しています。

「君、押されてるで」と教えたら色を変えて描画する仕組みをdirectionalButtonにいれてありますので、ユーザーの入力方向としてゲーム側が認識した方向キーのみ色を変えて「入力受け取ったで!」とアピールできます。ユーザビリティ的にも「押してる感」が出て良い感じです :smiley:

方向キーの当たり判定範囲をいい感じに調整する

さて、ここまでの実装でかなり良い感じの十字キーができあがりました。操作感の仕上げのために、各方向キーの当たり判定を調整します。

ここからは理屈ではなくて実際に動くものを触りながら合わせこんでいったもの、言うなれば kemokemo-fix ですので、方向キーにつかう画像の形状やご自身の好みで皆様なりに変更していただいた方が良いかもしれません。

という訳で、くだんの kemokemo-fix はこんな感じに調整しました。

rectangle_for_hit.png

  • 方向キーの短辺方向: ボタン幅と同じだけ左右に範囲を広げる
  • 方向キーの長辺方向: ボタン幅と同じだけ外側に範囲を広げる

まとめ

この記事では十字キーに特化してご説明いたしました。
次回はアクションゲームのジャンプやシューティングゲームのショットなどで使うようなトリガーキーについてご説明いたします。

VirtualGamePadサイコーʕ◔ϖ◔ʔ

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away