MIS.W 公式ブログ

早稲田大学公認、情報系創作サークル「早稲田大学経営情報学会」(MIS.W)の公式ブログです!

Unityで2Dアクションゲームを作ったので要点を解説します【カウントダウンカレンダー2021冬9日目】

はじめに

55代のえりくさー(@rust_elixir)と申します。

今年はUnityで2Dのアクションゲームを製作しており、プログラムをすべて担当しました。

キャラクターの挙動やそのアニメーション、各種当たり判定、各種ギミック、イベントシステム、エフェクトの表示、ライティングなど…ありとあらゆる部分を一人で作成しました。

このプロジェクトを通して学んだことを備忘録として簡単に残しておこうと思います。

当初は実際に書いたコードをがっつり使って説明しようと考えていたのですが、プログラミング研究会以外お断りの記事になりそうだったので、実装時の考え方や諸問題へのアプローチをプログラムを書いたことがない人にも伝わるように言語化することを目指しました。

そのため非常に文章が多く、なかなか長いものになってしまいました。

また、筆者は趣味でプログラムを書いてる「日曜プログラマー」なので、コンピューターを専門にしている人から見たらおかしい点があるかもしれません。その際は指摘していただいて温かい目で見守っていただけると幸いです。

とりあえず以下の4点について解説したいと思います。

地形判定

アクションゲームの核になる、地形の接触を判断する方法について説明します。

プレイヤーのアクション管理

プレイヤーのアクションの遷移と各種アクションの管理手法を説明します。

オブジェクトプール

ゲーム制作には欠かせないオブジェクトプールの概要と仕組みについて説明します。

イベントシステム

自作したイベント管理システムを説明します。

肝心のゲームなのですが、早稲田祭で展示しておりましたがその後諸問題が発生したため現在は公開を取りやめております。誠に申し訳ございません。諸問題が解決したらまた制作を再開したいと思いますので体験版の公開や来年の早稲田祭への展示を気長にお待ちください。

…失踪するかもしれないのでそこは先に謝っておきます。

また、一部に手書きの図を使用していてお見苦しい点があることも先に断っておきます。

一応参考程度にゲーム画面を何枚かはっておきます。

f:id:ELICXIR:20211221184820g:plain
アイテムの取得

f:id:ELICXIR:20211221194508g:plain
塔ステージ 敵との戦闘

f:id:ELICXIR:20211221221601g:plain
イベント(カギに対応した扉が開く)→新エリアテロップ

f:id:ELICXIR:20211221185050g:plain
洞窟ステージの一部

地形判定

アクションゲームに必須の機能といえば…そう、地形判定ですね。

地形判定はプレイヤーと地形との判定をとるというだけのものです。簡単に思うかもしれませんが、クオリティの高い地形判定を作るには意外と苦労します。

というのも、地形判定のクオリティを上げるためには以下のような問題を解決しなければならないからですね。

  • すり抜けの防止
  • 斜面の判定
  • 移動床

ちょっと詳しく解説しましょう。

すり抜けの防止

Unityに限らずすべてのゲームは非常に短い時間(一般的にフレームと呼ばれる)での処理を繰り返しています。 そのためこの「非常に短い時間」の関係で不適切な挙動が起こりうるのです。例えば、非常に速い速度で動くプレイヤーが非常に薄い壁にぶつかった際に壁にぶつかったことを感知できずに壁をすり抜ける現象が起こることがあります。

いうまでもなくアクションゲームですり抜けが起こると致命的なのですり抜けが起きないように判定を調整する必要があります。

すり抜けを悪用されるとゲームが崩壊しますからね…こんな風に↓

斜面の判定

斜面の当たり判定も意外と難易度が高い部分です。 というのも基本的にプレイヤーの接地判定は長方形でとっているため斜面に沿って動かすことが難しいんですね。画像の緑色の長方形が実際の接地判定なのですが、地面と点で接触しているため正確に接地判定をとることが難しいですね。

実は斜面の移動そのものよりも斜面と平面の境界部分の挙動のほうがめんどくさいのは内緒。

f:id:ELICXIR:20211221124006p:plain

移動床

移動床はアクションゲームの基本ですがここをしっかりと作らないとアクションゲームとして崩壊します。

↓移動床の処理をうまくできないと…こうなるんですね…

以上の問題を解決することは非常に重要な課題でした。

当初はUnityの物理演算機能を用いて当たり判定を実装しましたが、物理演算特有の挙動、特に接触判定の限界(実際に接触していなくても一定の距離を下回ると接触したと判断される)の影響で上にあげた3つの問題点を解決することができませんでした。

そのため、発想を転換し1から接触判定の部分を作りこみました。非常に複雑なプログラムになっておりここで詳しく解説することは難しいため簡単な仕組みの解説のみに留めておきます。

作成した接触判定システムについて

上記のUnity物理演算を用いた判定の問題となっていたのは時間の最小単位の問題でした。処理の行われる時間間隔は非常に短い(一般に16ms程度)ですが、それでも計算タイミングのずれや時間間隔の揺らぎの影響で意図しない挙動が出てしまう原因となっていました。

そのため発想を転換し「1フレームごとに移動を計算する」のではなく「1ピクセル分の移動ごとに地形認識と移動処理を行う」ことにしました。

グラフィックがドット絵であったため、グラフィックの1ドット(=1ピクセル)を最小単位にして動きを考えるようにしたんですね。

例えば1フレーム内にプレイヤーが右に5ピクセル、上に4ピクセル動く動きを考えてみます。通常ならばこの動きを一回の計算で処理します。

f:id:ELICXIR:20211222123336p:plain

そのため、この間に薄い壁があった場合、検知できずにすり抜けてしまいます。

f:id:ELICXIR:20211222123453p:plain

そこで、右に5ピクセル、上に4ピクセル動く動きを1ピクセルごとの動きに分解してその動きを毎回計算します。計算量は増えますが自然な挙動ができるようになります。

f:id:ELICXIR:20211222123552p:plain

薄い壁も検知できます。斜面の判定もよりスムーズに行えるようになります。

f:id:ELICXIR:20211222123633p:plain

その結果発生していた諸問題を解決することに成功しました。処理負荷が多少かかるため最適化が必要でしたが許容可能なパフォーマンスまで改善することができました。

移動床も問題なく実装できています。斜面の実装についても上にあるgifを見てください。いい感じに実装できています。

f:id:ELICXIR:20211221195811g:plain
移動床

プレイヤーのアクション管理

このゲームは2Dアクションゲームということもありプレイヤーには豊富なアクションが用意されています。 実装されているアクションをざっと確認してみましょう。

f:id:ELICXIR:20211221185737g:plain
プレイヤーのアクション

  • 立ち待機
  • しゃがみ
  • 歩行
  • ジャンプ
  • 落下
  • スライディング
  • レイピア
  • ナイフ投げ
  • 被弾(ノックバック)

ざっとあげただけでもかなりのアクションが実装されていますね。

これらのアクションをプレイヤーの入力やプレイヤーの現在の状況に応じて適切に切り替えることが必要です。

そのための管理システムを開発したのでその説明をしようと思います。

必要機能の確認

以下の機能を満たすように設計しました

  • アクションに応じたアニメーションを再生
  • アクションに応じた移動の挙動を実行
  • アクションの遷移に応じてアニメーションを調整する
  • 入力に対して適切にアクションを切り替える
  • 現在状況に対して適切にアクションを切り替える

入力に応じてアクションを切り替えるだけではなく、そのアクションに応じた挙動とアニメーションの管理も必要になります。また、現在状況に応じてアニメーションを切り替える必要もあります。例えば歩行中に崖から落ちたら歩行をやめて落下に移行する処理が必要です。

実装

アクションを切り替える部分と、アクションに応じた挙動とアニメーションをする部分を分割して設計しました。

加えて、直前のアクションが何であったのかを保存することで遷移のアニメーションも実装しました。例えば、「現在しゃがみ状態で直前が立ち状態であるならば立ちからしゃがみへの遷移アニメーションを再生する」とすると立ちからしゃがみへと移行した際にしっかりと遷移アニメーションが再生されます。(実際に上のgifを確認してみてください)

ここは実際のコードを見てもらいましょう。

歩行からほかのアクションに切り替える部分のコードです。

ソースコード

//歩行状態のときに実行される
case State.Walk:
                //残像エフェクトをオフにする
                DAIE.SetActive(false);

                //接地しているか(しているならisGroundはTrueになる)
                if (isGround)
                {

                    //回避ができる状態で回避の入力がされたら
                    if (canEvade)
                    {
                        if (C_InputDown(Control.Evade) && !buff_grounded)
                        {

                            //回避をする
                            Evade_Function();
                            break;
                        }
                    }

                    //十字キー下が入力されたら
                    if (C_Input(Control.Down))
                    {

                        //しゃがみに移行する
                        PlayerFlip();
                        TerrainBoxTypeChange(TerrainBoxType.Stand, TerrainBoxType.Crouch);
                        OverrideAction((int)State.Crouch);
                        break;
                    }

                    //ジャンプができる状態でジャンプの入力がされたら
                    if (canJump)
                    {

                        
                        if (C_InputDown(Control.Jump) && !buff_grounded)
                        {
                            //ジャンプする
                            PlayerFlip();
                            OverrideAction((int)State.Jump);
                            break;
                        }

                    }


                    //左右キーが入力されているか
                    if ((C_Input(Control.Right) && FrontVector.x > 0) || (C_Input(Control.Left) && FrontVector.x < 0) || (PM.OnForcedControll && BaseVelocity.x != 0))
                    {
                    }

                    //左右キーが入力されていないならば
                    else
                    {             
       
                        //立ち状態に移行する
                        OverrideAction((int)State.Stand);
                        break;
                    }

                }

                //接地していないとき
                else
                {
                    //落下に移行する
                    OverrideAction((int)State.Falling);
                    break;
                }

                break;

一見複雑に見えますが書いてある内容自体はそれほど難しくはありません。 移行する状況を適切に書いて条件が満たされたら移行するようにしているだけです。 この書き方だとアクションが複雑になったときに遷移の条件管理が煩雑になるという難点がありますが、現在の規模であれば管理できる規模で収まります。

一方でアクションに応じた挙動とアニメーションをする部分は非常に複雑になっていますので、ソースコードは載せますが詳しい解説は省きます(一応コメントはつけておいたのでそれを参考にしてください)。

ソースコード

//歩行のアクションを担うクラス
public class EA_Player_walk : PlayerAction
{
    //スプライト位置の調整用データ
    [SerializeField] SpriteObjectData sod;

    //煙のエフェクトのための位置情報
    [SerializeField] Transform FootMarker;

    //煙エフェクトを出す位置
    Vector2 pos
    {
        get
        {
            return (Vector2)FootMarker.position + player.FrontVector * -7 + Vector2.up * 3;
        }
    }

    //アクション開始時に呼ばれる
    public override IEnumerator Act()
    {
        bool f1 = true;
        bool f2 = true;

        //アニメーションの再生(アニメーション1ループ単位での処理をする)
        Play(ActionName);

        //アニメーションが終了するまでループ
        while (GetState.normalizedTime < 1)
        {
            //歩行スピードの調整
            float speed = 1 + player.buffManager.GetValue(BuffType.Speed);
            player.CustomPlayingSpeedMult = speed;

            //歩行時の速度の調整(加速時の慣性なども管理している)
            if (Input.InputVector().x > 0)
            {
                entity.BaseAcceleration.x = +player.PAV.WalkAcceleration;

                if (entity.BaseVelocity.x < 0)
                {
                    entity.BaseVelocity.x = 0;
                }
            }
            else if (Input.InputVector().x < 0)
            {
                entity.BaseAcceleration.x = -player.PAV.WalkAcceleration;

                if (entity.BaseVelocity.x > 0)
                {
                    entity.BaseVelocity.x = 0;
                }
            }
            entity.BaseVelocity.x = Mathf.Clamp(entity.BaseVelocity.x, -player.PAV.GroundBaseSpeed * speed, player.PAV.GroundBaseSpeed * speed);

            //煙を出す
            if (GetState.normalizedTime > 0.1f && f1)
            {
                f1 = false;
                GM.OBJ.SpriteObjectManager.GenerateByData(sod, pos, player.faceDirection == Entity.FaceDirection.Left);
            }
            //煙を出す
            if (GetState.normalizedTime > 0.6f && f2)
            {
                f2 = false;
                GM.OBJ.SpriteObjectManager.GenerateByData(sod, pos,player.faceDirection== Entity.FaceDirection.Left);
            }

            yield return null;
        }
        //アニメーション終了時に一度速度をリセット
        entity.BaseAcceleration.x = 0;
        //アニメーションの停止
        Stop();
        //当たり判定のリセット
        SettingReset();

    }

}

改良

今回はC#の継承を活用してよりスマートな実装を試みました。

プレイヤーの管理スクリプト(以下Player.cs)にアクションの遷移を書き、アクションに応じた挙動を別のスクリプトに書くことでPlayer.csのスリム化を試みています。

また、アクションに応じた挙動のスクリプトの部分についても、アクションが違ってもアニメーションを扱う機能やプレイヤーの当たり判定を調整する機能が必要なことは共通しています。(下図ではアニメーション制御、挙動制御としています)

そのため、その共通した部分を抽出して仮想クラスを作り、各アクションはその仮想クラスを継承しています。加えて仮想クラスの部分に各アクションの開始、終了を行うメソッドを持たせることで、Player.csは各アクションの開始メソッドと終了メソッドを呼び出すだけでよくなります。クラスをうまく活かした設計になったかと思います。

…とかいわれてもわからないと思うので図を用意しました。

実装初期はPlayer.csにすべての処理を書いていたため非常に複雑になっていました。アクションの追加などを行う際に苦労しました。

f:id:ELICXIR:20211222114453p:plain
実装初期:Player.cs内にすべての処理が書かれている

とりあえずアクションごとにスクリプトを分けてPlayer.csからはそのスクリプトを参照するようにしました。

f:id:ELICXIR:20211222115458p:plain
アクションごとにスクリプトを分割してPlayer.csをスリム化

上の状態からさらに、各アクションの共通部分を抽出して新しく基底クラスとしてaction.csを作ります。各アクションのクラスはaction.csを継承してaction.csをもとに作成されます。

f:id:ELICXIR:20211222120218p:plain
共通部分を抽出して継承を用いてさらにスリム化

こうすることで、共通する処理を重複して記述することを極限まで避けることができます。

オブジェクトプール

オブジェクトプールの必要性

ゲームを作るうえではオブジェクト(敵、弾 etc.)を生成したり破壊したりする必要が生じることがあります。

しかし、オブジェクトの生成と破壊は負荷が大きい処理なのでできるだけ避けたい処理でもあります。

そこで登場するのがオブジェクトをうまく管理してできるだけ生成と破壊を避けるオブジェクトプールという仕組みなんですね。

オブジェクトプールの実装

不要になったオブジェクトを破壊するのではなく一時的に保管することで破壊の負荷を避けることができます。

逆に新しいオブジェクトが必要になった時には保管されたオブジェクトを再利用することを優先して、どうしても必要な場合にのみ生成処理をすることで生成の負荷を避けることができます。

保管する際には非アクティブ化(簡単に言えば処理を一度休ませること)をして保管の負荷も下げています。

オブジェクトの保管と再利用について簡単に図を用意しました。

まず、オブジェクトプールのイメージはこんな感じです。三角形や四角形などの図形がオブジェクトを表しており、青はアクティブ状態、赤は非アクティブ状態であるとします。

ゲームで実際に使われているオブジェクトとプール(保管部)で非アクティブ状態で管理されているオブジェクトがあることに注意してください。

f:id:ELICXIR:20211222132858p:plain
オブジェクトプールのイメージ

次に、ひし形のオブジェクトがゲーム中で不要になった時を考えてみましょう。この時ひし形のオブジェクトは破壊されるのではなくプールに移動されて非アクティブになります。破壊の負荷を避けることができます。また、次にひし形が必要になった時にも再利用できます。

f:id:ELICXIR:20211222134206p:plain
ひし形オブジェクトの非アクティブ化

また、新しいオブジェクトが必要になったときにもプール内に待機されているオブジェクトに目的となるオブジェクトと同じものがあった場合再利用されるため生成の負荷を避けることができます。

f:id:ELICXIR:20211222135111p:plain
必要なオブジェクトがプール内にある場合

f:id:ELICXIR:20211222135226p:plain
プールに保管されていたオブジェクトが再利用される

最後に必要なオブジェクトがプールにない場合を考えてみましょう。三角のオブジェクトを生成したいけれどもプールに該当するオブジェクトがない場合とします。このときはじめて管理部が新しいオブジェクトを生成して需要を満たします。

f:id:ELICXIR:20211222134045p:plain
オブジェクトがプールにないためそのままでは需要を満たせない

f:id:ELICXIR:20211222134114p:plain
新しいオブジェクトが生成される

イベントシステム

このゲームにはNPCとの会話や各種ギミックの起動などのイベントが実装されています。上のgifにも騎士の死体からアイテムを拾う場面や扉が開く場面があったと思います。これらのイベントを効率的に、しかしイベント制作の拡張性を残しつつ管理することが求められていました。そのために要件を満たすイベントシステムを自力で開発しました。

コマンド式イベント実行システム

管理しやすく汎用性、拡張性の高いシステムを模索していたところ、コマンドを組み合わせてイベントを作成するという仕組みを考えました。これはイベントを構成する要素ごとに「コマンド」を作りそれを組み合わせて一連のイベントを作成するという手法です。コマンドは会話ウィンドウを表示しての会話、プレイヤーの移動(もちろん歩行アニメーションを再生しつつ)、アイテムの取得、扉の開閉、レバーの動作、ゲーム進行フラグの制御、フラグに応じた処理分岐、一定時間待機など多岐にわたります。

f:id:ELICXIR:20211221184820g:plain
アイテムの取得

例えばこの騎士のイベントコマンドは下図のようになっています。

f:id:ELICXIR:20211221222645p:plain
赤枠で囲った部分がイベントコマンド

会話とアイテム取得の2つのコマンドがセットされています。イベント自体にはセットされたコマンドを順番に実行するという機能しか持たせていませんが、コマンド側で多彩な機能を実装することで様々なイベントを作ることができるようになりました。

コマンドを作りこむことで下のような複雑なイベントも実装できるようになりました。

このイベントではプレイヤーが扉に接触したことを感知してイベントを開始し、次のようなコマンドを順に実行しています。

  1. プレイヤーを操作不能にして待機させる
  2. 扉のアニメーション再生
  3. 扉を開く処理
  4. プレイヤーをボス部屋内部に移動させる
  5. 特定地点まで来たら扉を閉める
  6. ボスの起動
  7. ボスのHPを表示
  8. プレイヤーを操作可能にする

f:id:ELICXIR:20211221223250g:plain
ボス戦開始イベント(移動は自動で行われています)

イベントコマンドを再生するという方式をとったことにより、すべてのイベントを一元管理することが可能になり非常に扱いやすいシステムが構築できました。

おわりに

読んでいただきありがとうございました。参考になるところがあれば幸いです。

長々と読んでいるとゲームプログラミングが難しいように見えるかもしれませんがUnityの機能や各種チュートリアルを活用すればそこそこの物が簡単に作れてしまいます。

なので、ゲームが作りたくなったら怯まずにまずは一歩を踏み出してみてほしいですね。

明日はプログラミング研究会会長のHarrisonKawagoe氏による記事のようです。幅広い知見があり技術力も高いまさにプログラミング研究会の人といった方なので個人的に期待しています。楽しみですね。