ぐーるらいふ

底辺。

【Unity】第10回unity1week「10」ベルトスクロールアクションシステムについて

お疲れ様です。ぐーるです。
今回はunity1weekで提出した
「School Bag Fight」
f:id:ghoul_life:20181127003802p:plain
School Bag Fight | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう
についての記事にしようと思います。

お題について

また困るやつ…w
勝手に「クリスマス」とか「お正月」
とか思ってたんですが、そんなものではなかった…w
まぁ、とりあえず作りたいものを作って
後から帳尻を合わせようかな、何か作りたいものはあるかな?
と言う所から考えることにしました。

作りたいもの?

挑戦したかったのは自分で描くスプライトアニメーションでした。
でとりあえず描き始めたのが徒歩モーション。

f:id:ghoul_life:20181127211549p:plain

これが完成した辺りで、そうだファイナルファイトみたいな
ベルトスクロールアクションを作ろうかなと思いました。

コアシステムについて

日本ではベルトスクロールと言いますが、
海外ではBeat em upとか言うのかな。

一見複雑に見えます(?)が、実はソースコードはそこまで長くはならなかったです。

四つのシステム

本当のコアの部分は以下の四つのクラスで構成されています。
一部抜粋で冗長になりそうなnullチェックとかrangeチェックとか消してます。

AreaManager
EnemyFactory
Player
Enemy

AreaManager

いきなりですけどコードを。

    public Camera _mainCamera;
    private int _index;
    private bool _isNext;
    private static Area[] _areas = new Area[]
    {
        new Area(-20.0f, -0.5f, -4.0f),
        new Area(-17.0f, -0.5f, -4.0f),
        new Area(-8.0f, -0.5f, -4.0f),
        new Area(-3.0f, -0.5f, -4.0f),
        new Area(2.0f, -0.5f, -4.0f),
        new Area(7.0f, -0.5f, -4.0f),
        new Area(12.0f, -0.5f, -4.0f),
        new Area(18.0f, -0.5f, -4.0f),
        new Area(23.0f, -0.5f, -4.0f),
        new Area(28.0f, -0.5f, -4.0f),
    };
    private Area _area;

    // 次のエリアへ移動可能
    public void Next()
    {
        _index++;
        _isNext = true;
        _area = _areas[_index];
    }

    void Update()
    {
        if (_isNext)
        {
            var p = _mainCamera.transform.position;
            if (p.x >= _area._targetX)
            {
                // 次のエリアへ到達。敵出現開始
                _isNext = false;
                EnemyFactory.Instance.NextStageStart();
            }
        }
    }

まずはAreaという概念から説明します。
ベルトスクロールアクションはよく

進む->止まって敵がワラワラと出てくる->全部倒したら->また次に進めるようになる

というイベントがあります。
これを1Areaとして管理することにしました。

Areaは

・目標X位置
・右端移動可能位置(算出)
・左端移動可能位置(算出)
・上に移動可能な位置
・下に移動可能な位置

とrectと似たようなデータを持つクラスです。
AreaManagerはこれをArea数分持っています。
そしてAreaの切り替わりはCameraの位置によって決めています。

1. 敵を全部倒した
2. AreaManagerの次のエリアデータをセットして、次に進んでいいよフラグを立てる
3. フラグが立っている時は目標X位置にカメラが来るまで待機
4. 目標X位置までカメラが到達したら進んでいいよフラグを降ろし敵を出現させる

という流れになります。

3.の状態の時、横着してまして、
左(Xマイナス方向)に進むことを出来なくしています。
CineMachineとか使ってたら範囲内ならプレイヤーだけ移動、範囲外に出たらカメラごと移動
という良いUXに出来たなーと後でちょっと思いました。今後の課題

EnemyFactory

読んで字のごとく敵を生成するクラスです。

    private List<EnemyScript> _enemyList;
    private List<Bullet> _bullets;
    private int _createCount = 0;
    private int _disposeCount = 0;
    private float _popTimer = 10;
    public bool _running = false;

    // int enemyCount, float moveSpd, float countDownBeforeSpd, float countDownAfterSpd, float popSpd 
    private StageData[] _stages = new StageData[]
    {
        new StageData(1, 100, 1, 10, 5),
        new StageData(3, 5, 2, 50, 3),
        new StageData(5, 5, 2, 50, 1),
        new StageData(1, 100, 1, 30, 3),
        new StageData(5, 10, 3, 30, 2),
        new StageData(10, 10, 2, 30, 1),
        new StageData(7, 3, 5, 50, 1),
        new StageData(7, 3, 4, 50, 0.5f),
        new StageData(10, 10, 7, 50, 2),
        new StageData(1, 100, 10, 100, 1),
    };

    private StageData _stage;

    // ステージを開始
    public void NextStageStart()
    {
        _running = true;
        _stage = _stages[AreaManager.Instance.Index];
    }

    public void CreateEnemy(StageData stage, Vector3 pos){ /* 敵を生成。リストに保存 */ }
    public void CreateBullet(bool fripX, Vector3 pos, Vector3 vector){ /* 弾を生成して発射。リストに保存 */ }

    public void DisposeEnemy(EnemyScript enemy)
    {
        if(enemy != null)
        {
            _disposeCount++;
            if (_enemyList != null)
            {
                _enemyList.Remove(enemy);
            }

            Destroy(enemy.gameObject);
            // 敵を倒したカウントを計測しておいて、出現数に到達したら次のエリアへ
            if(_disposeCount == stage._enemyCount)
            {
                _running = false;
                AreaManager.Instance.Next();
            }
        }
    }

    public void DisposeBullet(Bullet bullet){ /* リストから消してDestroy */}

    void Update () {
        if (_running)
        {
            if(_createCount < _stage._enemyCount)
            {
                _popTimer += Time.deltaTime;

                if (_popTimer > _stage._popSpd)
                {
                    _popTimer = 0;
                    var pos = _player.transform.position;
                    var rand = Random.Range(0, 100);
                    if (rand > 50)
                    {
                        pos.x += 10;
                    }
                    else
                    {
                        pos.x -= 10;
                    }
                    pos.x += Random.Range(-0.5f, 0.5f);
                    pos.y += Random.Range(-0.5f, 0.5f);

                    CreateEnemy(_stage, pos);
                    _createCount++;
                }
            }
        }
    }

EnemyFactoryはstageという情報を持っています。
Stageは

・敵の出現数
・敵の移動速度、攻撃速度
・敵のポップ速度

といった難易度パラメータを持っています。
この値とAreaが1:1で繋がっています。
(だったら一つのScriptableObjectにまとめとけ!って後で思いました)

この値に沿って敵を出現させるのですが、
まぁよくある手でListでEnemyもBulletも保持します。
これはOPに戻ったりした時にまとめてリセットをかけるために持ってます。
破棄する時に一緒にListからも外す必要が出てくるのでちょい面倒ではありますが、しょうがない。

Player

Playerは操作感というか
キーを離したらぴたっと止まるようにとか
攻撃速度を早くとかキックしているときだけ若干の硬直を入れてたりといった小細工を入れている程度なんですが、
一つだけ問題が出た所はContinueのフローでした。

f:id:ghoul_life:20181126235255p:plain
こちらPlayerのAnimatorになります。

敵にやられた時、ここのdown状態のStateで止まっていて
コンティニューを選んだ時にwaitに戻るのですが、最初はここをTriggerで管理してました。
一度目は良いのですが、二度目コンティニューをしようとすると即座にwaitに遷移してしまう、

というバグを生み出してしまいました。
なので、ここをfloatに変えて、値が一定以上ならwaitに戻り、戻ったら0で上書きしておく
と修正しました。

f:id:ghoul_life:20181127213507p:plain
死亡した時の画面。ゲーム全体を止めていて
発射モーション途中で止めたいと思ってそこは意識してます。

Enemy

State管理が重要です。

SEARCH
PATROL
ATTACK_BEFORE
SHOT
ATTACK_WAIT

の五つがあります。

        switch (_enemyState)
        {
            case EnemyState.SEARCH:
                _targetPosition = _player.transform.position;
                _targetPosition.x += Random.Range(-0.1f, 0.1f);
                _targetPosition.y += Random.Range(-0.1f, 0.1f);
                _enemyState = EnemyState.PATROL;
                break;
            case EnemyState.PATROL:
                _animator.SetFloat("walk", 1.0f);
                var move = _moveSpeed * Time.deltaTime;
                transform.position = Vector3.MoveTowards(transform.position, _targetPosition, move);
                if(Vector3.Distance(transform.position, _targetPosition) <= STOP_DISTANCE)
                {
                    _animator.SetFloat("walk", 0.0f);
                    _enemyState = EnemyState.ATTACK_BEFORE;
                    /* パラメータセット */
                }
                break;
            case EnemyState.ATTACK_BEFORE:
                if (_countdownTimer > 0)
                {
                    _countdownTimer -= _countdownBefore * Time.deltaTime;
                    // playerが離れすぎたら再度searchへ
                    if(Vector3.Distance(transform.position, _player.transform.position) > 8)
                    {
                        _enemyState = EnemyState.SEARCH;
                    }
                }
                else
                {
                    _enemyState = EnemyState.SHOT;
                    /* パラメータセット */
                }
                break;
            case EnemyState.SHOT:
                _animator.SetTrigger("gun");
                var vector = (_player.transform.position - transform.position).normalized;
                _enemyFactory.CreateBullet(_spriteRenderer.flipX,this.transform.position, vector);
                _enemyState = EnemyState.ATTACK_WAIT;
                /* パラメータセット */
                break;
            case EnemyState.ATTACK_WAIT:
                if (_countdownTimer > 0)
                {
                    _countdownTimer -= _countdownAfter * Time.deltaTime;
                }
                else
                {
                    _enemyState = EnemyState.SEARCH;
                }
                break;
        }
SEARCH

プレイヤーの位置を取得して、若干ランダムで位置をずらして保持します。
その後PATROLに遷移

PATROL

SEARCHで取得した位置に移動します。
移動速度はStageパラメータで設定します。
目標地点に到達したらATTACK_BEFOREに入ります。

ATTACK_BEFORE

攻撃前の待機です。構えと言い換えてもいいです。
10秒のカウントダウンを行い、SHOTに遷移します。
このカウントダウン速度もパラメータで設定。
この時プレイヤーが離れすぎた場合は再度SEARCHに遷移します。

SHOT

弾を発射します。animatorに合わせて微調整入れてます。

ATTACK_WAIT

弾発射後の硬直です。
ここもパラメータ
硬直が解けたら、再度SEARCHに遷移して、プレイヤー位置を上書きします。

その他

後は普通にColliderで当たり判定を作っていたり
画面遷移、_animatorをストップさせて会話パートをゲーム中に挟んでみたりと
といった細かい実装なので割愛。

終わってみて

企画、システム(ゲーム)、デザイン
をわずか一週間で形にする…。これは大変な事です。
ちょっと無謀だったな…というチャレンジでしたが、
なんとか遊べるレベルにまで持っていけてよかったです。
そして評価、コメントまで頂けて本当に嬉しいです。

余談

作業中はよく適当にアニメを流しているんですが、今回は
ガールズアンドパンツァーをTV、OVA、劇場版、最終章と続けて二周ぐらい流してました。
次は戦車にしようかなw