はきだめ

プログラミングのこととか色々

3Dアクションゲーム開発記

今回の内容は昔作った3Dアクションゲームの開発記です。なんでこの記事を書こうと思ったのかというと、先日1年ぶりにunityに触ったときに3Dの知識が吹っ飛んでいて苦労したので、またすぐに忘れないように開発の記録をさらっと自分用にまとめようと思ったからです。

ちなみに昔作ったのはこちらのゲームです。
https://unityroom.com/games/monster-island

上記で作ったとか言っちゃってますが、6~7割ぐらい参考にした本の内容と一緒です(unity初心者なので勘弁してください…)。参考にした本というのはコチラです。

Unityゲーム開発 オンライン3Dアクションゲームの作り方

Unityゲーム開発 オンライン3Dアクションゲームの作り方

ゲーム内のキャラクターのモデルとかアニメーションとかサウンドとかも本を買ったときについてきたものです。自分で作ったのはフィールドだけですが、これはterrainで山作って草生やしてテクスチャ塗り塗りするだけなので結構簡単です。そんな感じで基本的に参考書に沿って作った上で少し改造しました。
ここから先は開発の流れについて書いていくつもりですが、ゲーム作る上で調べたこととか、重要そうなこととか、試行錯誤したところとかをメインにメモっていこうと思います。

3Dゲームを作る上で必要な知識

Tag(タグ)

gameobjectの管理をしやすくするために使う。「プレイヤー」「敵キャラ」のようにグループ分けするのに必須。

Layer(レイヤー)

複数のカメラを設置したゲームでカメラごとに映るものを分けたり、衝突判定を行う組み合わせを決めたりするときに使う。

ライトの種類
  • Direction Light…平行光源

  • Point Light…点光源。360度に光を発する。

  • Spotlight…スポットライト。円錐状に光を照らす。

  • Area Light…面光源

影の付け方

通常はNo Shadowsとなっているが、Hard Shadowsにすれば影を付けられる。場所はlightの設定と同じ場所。ただし、影は処理負荷が比較的高いらしい。

カメラのインスペクタビュー
  • Backgroud – 背景色を指定
  • Culling Mask – このカメラで描画するLayerにチェックを入れると、チェックしたLayerだけがカメラに映る。
Gameビュー

f:id:kurome-stdio:20170604131847p:plain

左からゲームの再生、一時停止、ステップ実行。ステップ実行をすると処理が1フレーム進む。

  • Stats(スタッツ)

f:id:kurome-stdio:20170705222612p:plain

3Dゲームなら30~60fpsは維持すべきラインらしい。
あとTris(ポリゴン)よりもVerts(頂点数)の方が重要(Vertsが多いと重い?)らしい。詳しくはここらへんの記事が参考になりそう。

d.hatena.ne.jp

プレハブ

オブジェクトの大元みたいなイメージ。プレハブを一つ作ることでその複製を簡単に作ることができ、またプレハブから複製されたインスタンスはプレハブの設定が変わったときにインスタンス(複製)の設定も同時に全て変えることが出来る。
作り方としてはPrefabというフォルダーにシーン内の配置されているオブジェクトをそのまま放り込むことで生成することが出来る。オブジェクトの設定をプレハブの設定に戻したいときはInspecterビューの上部にあるRevertボタンを押す。逆にシーンに配置したオブジェクトの設定をプレハブに反映させたい場合はApplyボタンを押す。

AnimationType

f:id:kurome-stdio:20170705234633p:plain

  • Humanoid…人用のアニメーション
  • Generic…人以外に用いるアニメーション。
  • None…アニメーションを使わないモデルデータ用
  • Legacy…Unity3.x以前のアニメーションシステムとの互換性のために残されている設定
Vector3の便利な関数
  • Vector3.Dot…ベクトルAとベクトルBの内積を返す。
  • Vector3.Lerp…ある点からある点の直線線形の割合を求める。
  • Vector3.normalized…ベクトルの正規化
  • Vector3.Distance…2点間の距離を返す

Lerpなどの補完系に関してはこれらの記事を参照したほうが良さそう

www.blueraja.com

gametukurikata.com

オイラー角とクォータニオン

オイラーオイラー角は3つの角度の値を X、Y、Z 軸に順に当てはめて回転を表す簡易な方法。
クォータニオンクォータニオン成分 (x, y, z, w) の4つを用いてオブジェクトを回転させる方法。

オブジェクトを回転させるときにはx軸、y軸、z軸の3つをインスペクタ上でいじったりすると思いますが、実は内部ではクォータニオンに変換して回転させてます。深い話は一次変換とか行列の話になってしまいますが基本的にクォータニオンに関しては、知らなくてもゲームが作れるので問題ないと思います。この辺の話はこれらの動画がものすごく丁寧に解説しているのでそちらを参照にするのがいいと思います。特にクォータニオン完全マスターはスライドも分かりやすいし、数学の勉強にもなります。

【Unity道場 博多スペシャル 2017】クォータニオン完全マスター - YouTube

【MMD】物理演算の計算順序について【お詫びと訂正】 by すず その他/動画 - ニコニコ動画

Time.deltaTime
  • Time.deltaTime…前回のUpdate関数が呼び出されてからの経過時間が格納されている(単位は秒)
    例えば1秒間に1m動かしたいと思ったときに
function Update () {
    transform.position.x += 1;
}

って書いてしまうとUpdate関数が呼ばれる頻度はパソコンによって変化するので上手くいかないと思います。スペックが高いパソコンだと120フレームぐらい読み込まれますが、逆にしょぼいパソコンだと30フレームぐらいしか呼ばれないため1秒間に30〜120mぐらい動いてしまうという問題が起きます。そこで1秒間に動いて欲しい距離にTime.deltaTimeを掛けてやると、Time.deltaTimeの中身はUpdate関数が呼び出されてからの経過時間なのでどのパソコンでも1秒間に1m動かすことが可能になります。

function Update () {
    transform.position.x += 2 + Time.deltaTime;
}

この場合だと例えばUpdate関数が1秒間に60回呼び出されるとしたら(60/60)*2で2m進むという感じです。

キャラクターの移動

まず初めにやったのがキャラクターを移動させるプログラムを書くことですが、その前に地形(フィールド)を作る必要があります。こちらは適当に自作しました。

キャラクターを移動させるには

インポートしてきた3DモデルにCharacter Controllerを付けます。Character Controllerは人やモンスターなどのキャラクターを移動させるのに特化したコンポーネントのことで、使い方としてはMove関数に移動量をVector3で渡してあげるだけです。
一番シンプルな例(WASD移動の場合)

 public float speed;
    public const float gravity = 9.8f;
    public CharacterController controller;

    void Update () {
        float x = Input.GetAxis ("Horizontal");
        float z = Input.GetAxis ("Vertical");
        Vector3 v = new Vector3(x * speed * Time.deltaTime, -gravity * Time.deltaTime, z * speed * Time.deltaTime);
        controller.Move (v);
    }

上のコードではキャラクターを地面に設置させるために重力を加算してます。RigitBodyを付けてaddforceで動かすだけならこの処理は要りませんが、CharacterControllerを使って動かす場合はこの処理が必要です。重力を加える処理が必要になりますが、基本的にCharacterContorollerを使ったほうが簡単なので今回はこちらを使いました。

移動を滑らかにするには

上のコードだと移動が等速直線運動になってしまうためあまり自然な動きにはなりません。そこでゆっくり動き出して、ゆっくり停止させるために速度を補間する必要があります。例えば1/3秒で目的の速度に達するようにするには、

t = 1フレームの時間×3
ただし、t>1のときはt=1として、
移動速度ベクトル = (方向×移動速度)×t + 現在の移動速度ベクトル×(1 - t)

とすれば滑らかに移動させることが出来るようになります。というわけでさっきのコードを少し変えてスムーズに移動できるようにしました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerMove : MonoBehaviour {
    public float speed;
    public const float gravity = 9.8f;
    Vector3 old_velocity = new Vector3(0,0,0);

    public CharacterController controller;

    void Start () {
    }

    void Update () {
        float x = Input.GetAxis ("Horizontal");
        float z = Input.GetAxis ("Vertical");
        Vector3 velocity = new Vector3(x * speed * Time.deltaTime, -gravity * Time.deltaTime, z * speed * Time.deltaTime);
        velocity = Vector3.Lerp(old_velocity, velocity, Mathf.Min(Time.deltaTime * 5.0f, 1.0f));
        controller.Move (velocity);
        old_velocity = velocity;
    }
}

動き始めてから0.2秒後に最大の速度になるようにしました(Time.deltaTime * 5.0fの部分)。さらにゆっくり減速させたい場合はこの値を小さめ、逆にクイックな反応にしたい場合は大きめな値に設定すればいいと思います。またMin関数で補間係数が1.0以上にならないように制限しています。

補間前

f:id:kurome-stdio:20170604163748g:plain

補間後

f:id:kurome-stdio:20170604164412g:plain

補間後のほうがより自然な動きになっていると思います。

さらにゆっくり減速させた場合(5.0f→1.0f)

f:id:kurome-stdio:20170604164622g:plain

こちらはさらにゆっくり減速させた場合です。ここまでやると滑っているように見えます。
本だとクリックした場所にキャラクターを動かすようにしていますが、普通にWASDで動かせるようにしたかったのでここは弄りました。あとクリックした場所にキャラクターを動かすとなるとクリックした場所の座標をワールド座標に変換するという処理が必要でなおかつベクトルの計算も結構複雑であまり自分が理解していないということもあってWASD移動にしました。

カメラの移動

次にカメラがキャラクターを追随するようにします。ここは本のスクリプトをそのまま使いました。

考え方としては
カメラの位置=追随対象の位置+ずらし位置
として、Lookatで追跡対象の方向を向きながら追跡対象が動いたときにカメラも一緒に移動させるって感じです。

ここでついでにカメラの向きを正面にしてキャラクターが移動出来るようにしました。コードは以下のようになりました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerMove : MonoBehaviour {
    public CharacterController controller;
    public float speed;
    public const float gravity = 9.8f;

    Vector3 move_direction; 
    Vector3 old_velocity = new Vector3(0,0,0);

        Transform cam_trans; 

    void Update () {

        //// カメラの方向を正面にして動かせるようにする
        move_direction = (cam_trans.transform.right * Input.GetAxis("Horizontal")) + (cam_trans.transform.forward * Input.GetAxis("Vertical"));
        move_direction *= speed * Time.deltaTime;

        //重力
        move_direction = move_direction  + new Vector3(0, -gravity * Time.deltaTime, 0);

        //Lerpで滑らかに移動させる
        move_direction = Vector3.Lerp(old_velocity, move_direction, Mathf.Min(Time.deltaTime * 5.0f, 1.0f));

        //移動
        controller.Move (move_direction);
        old_velocity = move_direction;
    }
}

アニメーション

次にキャラクターにアニメションを付けていきます。アニメーションに関してはデータさえあればあまりプログラミングせずに実装することが出来るのでunityは本当に凄いです。

敵のアニメーター
f:id:kurome-stdio:20170705234637p:plain

具体的にはAnimatorというものを作ってその中に移動のアニメーションとか攻撃のアニメーションとかを放り込みます。矢印はアニメーションの繊維条件です。ここで注意しておく必要があるのはちゃんとモデルデータとAnimetionTypeが一致しているかどうかです。ここで人のようなキャラクターモデルにAnimationTypeがGenericのアニメーションを結びつけて再生すると人ならざる動きをするので注意が必要です。当時はここを見落としてだいぶ時間をくった記憶があります。他にもすぐにアニメーションを遷移させる場合にはHas exit timeのチェックを外す必要があります。Has exit timeにチェックしたままだと遷移条件を満たしていても、再生しているアニメーションが再生し終わるまで遷移しません。
このゲームではキャラクターの移動速度が秒速0.4以上になったときに走るアニメーションに遷移するようにしています。

f:id:kurome-stdio:20170706215914p:plain

最初はWASDキーまたは矢印キーが押されたときに走るアニメーションに遷移するようにしていましたが、それだとキーを離した瞬間に止まったときのアニメーションになってしまい少し不自然だったのでここは本に書いてあるように前回のUpdateからの移動量を計算してspeedパラーメータに値を渡して遷移するかしないかを区別しています。

Vector3 delta_position = transform.position - prePosition;
animator.SetFloat("Speed", delta_position.magnitude / Time.deltaTime);

攻撃の実装

次に行ったのがアクションゲームのメインとなる攻撃の実装です。オブジェクトとオブジェクトの接触の感知には基本的にはコライダーを使います。具体的には攻撃側(武器など)と攻撃を受けるキャラクター(胴体部分など)にコライダーを取り付けて、接触したときにライフを減らす関数を呼び出すということをします。また接触したときに、物理的な作用をさせたくない場合にはIs Triggerにチェックする必要があります。またコライダーを取り付けたオブジェクトを移動させるときはRigitbodyコンポーネントをつける必要があります。ここでRigitbodyを付けないと物理エンジンがオブジェクトが移動しないものと認識するため衝突計算がおかしくなります。またRigitbodyは処理が重いのでむやみにつけないほうがいいみたいです。衝突判定だけを行いたい場合はRigitbodyのIs Kinematicにもチェックを行う必要があります。つまり物理的な処理をさせずにオブジェクトの衝突判定をするには

攻撃側にコライダーを取り付ける
↓
攻撃を受ける側にコライダーを取り付ける
↓
コライダーのIs Triggerにチェック
↓
RigitbodyのIs Kinematicにチェック

という流れになります。衝突判定を行って接触を感知したときに関数が呼ばれるようになったのであとはそれぞれのコライダーがある部分にスクリプトを埋め込んでスクリプト側で処理していきます。

攻撃側

//接触すると呼ばれる
void OnTriggerEnter(Collider other){
    //攻撃を受ける側に情報(攻撃力)を渡す
    other.SendMessage("Damage",GetAttackInfo());
}

攻撃を受ける側

void Damage(AttackArea.AttackInfo attackInfo){
    //渡された攻撃力などの情報をそのまま親のオブジェクトに渡す(Damage関数を呼ぶ)
    transform.parent.SendMessage ("Damage",attackInfo);
}

親のゲームオブジェクト

//ライフから攻撃力分の値を引く
void Damage(AttackArea.AttackInfo attackInfo){
    status.HP -= attackInfo.attackPower;
}

本では攻撃を受ける側のスクリプトではtransform.rootで参照していますが、下の写真のようにEnemyという空のフォルダを作ってそこに敵のプレハブを放り込んでヒエラルキーが見やすくなるように変更したときにtransform.rootだとEnemyというフォルダが参照されてエラーになるということがありました。なのでtransform.parent(コライダーの親が欲しいオブジェクト)でDamege関数を記述しているオブジェクトを参照することが出来ました。

階層
f:id:kurome-stdio:20170706230056p:plain

敵のAIの実装

最後にAIの実装です。敵のアルゴリスムは以下のようになっています。

待機
↓
徘徊
↓(もし半径5m以内にプレイヤーが居たら)
追跡開始
↓(もし半径2m以内にプレイヤが居たら)
攻撃
↓
待機

半径5m以内にプレイヤーが居るかどうかの判定はコライダーの接触判定を使います。さらにプレイヤーやどうかの判断はtagで識別してプレイヤーだった場合はenemyCtrlスクリプトのSetAttackTarget関数を呼び出して変数に位置情報を保持させます。

void OnTriggerStay( Collider other ){
    // Playerタグをターゲットにする
    if( other.tag == "Player" ){
        //プレイヤーの位置情報を渡す。
        enemyCtrl.SetAttackTarget( other.transform ); 
    }
}

追跡と待機は主に乱数で決めています。

Vector2 randomValue = Random.insideUnitCircle * 5;
Vector3 destinationPosition = transform.position + new Vector3(randomValue.x, 0.0f, randomValue.y)

Random.insideUnitCircle関数で半径1の円の中の点をランダムに選んでもらい、5倍して現在居る位置にrandomValueの値を足します。ここで高さは要らないので切り捨てます。

waitTime = Random.Range(2.0f, 4.0f);

待機時間もRandom.Range関数を用いて(2秒から4秒)バラバラになるようにしています。追跡に関しては本では目的地とオブジェクトのベクトルの差を求めて正規化し方向だけを取り出してから速さを掛けてCharacter.Moveで動かすということをしていますがナビゲーション機能を使うともっと簡単に追跡するプログラムを掛けそうなのでいずれナビゲーション機能を使ってここの部分を書き換えたいと思います。

敵キャラをUnityのナビゲーション機能を使って移動させる | Unityを使った3Dゲームの作り方(かめくめ)

付け足したところ

ここまででアクションゲームの骨組みは大体完成です。エフェクトと音の鳴らし方などは他にもいろんなサイトでやり方が載っているので省略します。大きく弄ったのはプレイヤーの移動と地形とかなのであんまり追加した要素はないのですが、覚えている限りの試行錯誤したところやボツになった案などをメモっておこうと思います。

ライフバーの表示

まず初めに敵の上部にライフバーを設置したことです。

ライフバー
f:id:kurome-stdio:20170707231811p:plain

といってもこちらのサイトを参考にしたので1から自分で考えたわけではないです…。

Unityで敵キャラクターのHPを頭上に表示するUIを作成 | Unityを使った3Dゲームの作り方(かめくめ)

こちらのサイトではJavaScriptで書かれていますが、それをC#に変換して自分のゲームでも動くように書き換えたらこんな感じになりました。

using UnityEngine;
using System.Collections;
using UnityEngine.UI;

public class SetHp : MonoBehaviour {

    private UnityEngine.UI.Slider slider;
    private CharacterStatus status;
    private int count;
    private int countNow;

    void Start () {
        status = GetComponentInParent<CharacterStatus> ();
        slider = GetComponent<UnityEngine.UI.Slider>();
        slider.value = slider.maxValue;
        count = status.MaxHP;
    }
    
    void Update () {
        countNow = status.HP;

        if(countNow != count) {
            slider.value = (countNow * slider.maxValue) / status.MaxHP;
            count = countNow;
        }
  }
}

基本的に下の写真みたいなcanvasを作って上のスクリプトをsliderに貼れば動くんじゃなかと思います。

f:id:kurome-stdio:20170707233636p:plain

ただこのままだと、ライフバーは常にカメラの方向いているわけではないので横からみると何も見えないという状況に陥りました。なので下記の記述をすることでライフバーをカメラの方向に向かせることが出来ました。

transform.rotation = Camera.main.transform.rotation;
CharacterController同士が重なるバグの修正(?)

こちらは新たな機能を追加した訳ではありませんが、ステージ内の少し高いところから敵のキャラクターに向かってキャラクターを移動させるとそのまま敵のキャラクターの上に乗っかることが出来てしまうというバグが起きました。具体的には以下の記事のような現象です。

【Unity Tips】 CharacterControllerでコリジョンを滑り落ちよう: Karasuのアプリ奮闘記

恐らくステージに坂道が多かったのが原因だと思います。対応策としてはキャラクターの下向きにRayを飛ばし少しでも傾斜があったら滑り落とすという処理を加えました。ただどのように滑り落とせばいいか分からなかったため、とりあえずx軸方向とz軸方向にそれぞれ0.5ずつずらすことにしました。多分もっといい方法があると思いますが、スクリプトは以下のようになりました。

using UnityEngine;
using System.Collections;

public class slide : MonoBehaviour {


    private PlayerMove Move;
    private Vector3 slide_direction;

    public float slide_magnitude = 0.5f;

    const float gravity = 9.8f;
    bool isSliding = false;

    RaycastHit slideHit;
    CharacterController chara;

    void Start () {
        Move = GetComponent<PlayerMove>();
        chara = GetComponent<CharacterController>(); 
    }

    void Update () {
        //下に敵が居て傾斜が0度以上だったら
        if (Physics.Raycast(transform.position, Vector3.down, out slideHit, LayerMask.NameToLayer("Defaul"))) {
            if (Vector3.Angle(slideHit.normal, Vector3.up) > 0){
                isSliding = true;
            }else{
                isSliding = false;
            }
        }

        if(isSliding){
            Vector3 hitNormal = slideHit.normal;
            slide_direction.x = hitNormal.x * slide_magnitude;
            slide_direction.y -= gravity * Time.deltaTime;
            slide_direction.z = hitNormal.z * slide_magnitude;
            chara.Move(slide_direction); 
        }
    
    }
}

確かに上のソースコードで滑り落ちるようになりましたが、それでも理由は分かりませんがたまに乗っかってしまうことがありました。なので強硬策としてCharacterControllerを縦に引き伸ばすということをしました。結果としてCharacterControllerが普通にプレイする上では重なることはなくなりましたがここはもう少し改善する必要があります。

マップの実装(ボツ)

これはフィールドが広くて分かりづらいという意見があったので、マップを実装したときの写真です。

f:id:kurome-stdio:20170708001922p:plain

実装にあたって以下のサイトを参考にしました。

Unityのゲームでキャラクターの動きを追うレーダーカメラを作成する | Unityを使った3Dゲームの作り方(かめくめ)

上のサイトを見れば分かるようにマップといっても二つ目のカメラでステージの上からの景色をそのまま表示させているだけです。そのため木が生い茂っている場所に移動するとプレイヤーの位置が木に隠れて見えない画面の右上にあるのが邪魔といった問題があってボツにしました。マップを実装するとしたらメニュー画面を開いたときだけマップを画面上にだすとか、フィールドのミニマップを描くなどをする必要があると思います。当時は間に合いませんでした。

まとめ

以上がゲームを作るまでの流れです。攻撃の部分と敵のAIの部分は本のスクリプトをそのまま使いましたがAI部分はもうちょっと改善できそうな気がします。ちなみにゲームを制作期間は大体2ヶ月ぐらいです。せっかくなのでunityインターハイにこの作品を提出しましたが予選で敗退してしまいました…。大会の審査員からはこんなフィードバックを頂きました。

  • 敵の狼が強い。市販の3Dアクションゲームを見ると複数の敵が同時に近づいてくることはあっても、同時に襲ってくることはない(片方は間合いの外でじわじわ歩き回ったり)ので、そうした動きを研究してみてほしい。

  • アクションの駆け引きがもっとほしい。

てっきり予選通過したかどうかの連絡しかこないと思っていたのでフィードバックが貰えたのは結構嬉しかったです。Unityroomの方でもコメントがついたりしててとても励みになりました。
以上が、去年作った3Dアクションゲームの開発記です。