年の功より亀の甲

カメがプログラミングとか技術系について書くブログです。

Global Game Jam 2020に参加してきました - Part2 -

カメドットです。

今回は、第2弾です。

discordネタがいくつかあるので、興味がある方で札幌GGJにいらっしゃる方は以下のリンクから見れたりしちゃいます

discordapp.com



初日のお話

  • 開発環境のセットアップの話

3Dゲームなのでモデリングソフトとゲームエンジンのバージョン確認をしていました。

Unity 2019.3.06f
Blender 2.79

  • 見積もり的なお話

2020/02/06 差分が生まれる前提条件を加筆










一番重たいところのお話が合ったけど、初Unityと規模間的にサポート重視のほうが確実性高いので。
自分では難しいと相談して、黒子系をやっていました

加筆開始箇所(つよつよな学生さんに作っていただいた内容です)

重たいところの見積りのお話
・せっかくだからInterfaceを用意して、使いたい
・初日23時時点で、企画整理中

実際に実装されたコード

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

public class Warrior : MonoBehaviour, ICharacter
{
    /// <summary>
    /// 戦士のステータスデータ
    /// </summary>
    private WarriorStatusData warriorStatusData = default;
    private WarriorStatus warriorStatus = default;
    /// <summary>
    /// 戦士の体力
    /// </summary>
    public int HitPoint = 100;
    /// <summary>
    /// 戦士の攻撃力
    /// </summary>
    public int AttackPower = 10;
    /// <summary>
    /// 戦士の防御力
    /// </summary>
    public int GuardPower = 10;
    /// <summary>
    /// 戦士のタイプ
    /// WarriorStatus参照
    /// 今のところ0=Attack,1=Deffence
    /// </summary>
    public int WarriorType = 0;
    /// <summary>
    /// どっちのチームか
    /// 1=1P,2=2P
    /// </summary>
    public int TeamID = 1;
    /// <summary>
    /// 攻撃中かどうか
    /// </summary>
    public bool IsAttack = false;
    /// <summary>
    /// 死んでいるかどうか
    /// </summary>
    public bool IsDeath = false;
    /// <summary>
    /// 攻撃の間隔
    /// </summary>
    public float AttackInterval = 1;
    /// <summary>
    /// ↑を計算するために必要な一次変数くん
    /// </summary>
    public float tempTime;
    /// <summary>
    /// ゲームが終わったかどうか
    /// </summary>
    public bool IsEnd = false;

    ///<summary>
    ///効果音
    /// </summary>
    public AudioClip attackSE;
    public AudioSource audioSource;

    /// <summary>
    /// 戦士のNavMesh
    /// </summary>
    public NavMeshAgent agent;
    /// <summary>
    /// 戦士のアニメーターコントローラー
    /// </summary>
    public Animator WarriorAnimator = default;

    /// <summary>
    /// 初期化処理
    /// </summary>
    void Start()
    {
        // 戦士のプロパティを取得
        warriorStatusData = Resources.Load<WarriorStatusData>("WarriorData");
        warriorStatus = warriorStatusData.WarriorStatusList[WarriorType];
        this.HitPoint = warriorStatusData.HP;

        // 1Pと2Pのマテリアルを変更
        this.GetComponentInChildren<SkinnedMeshRenderer>().material = warriorStatus.WarriorMaterials[TeamID - 1];
        agent = this.GetComponent<NavMeshAgent>();
        WarriorAnimator = this.GetComponent<Animator>();
    }

    /// <summary>
    /// 何かに衝突した時に起きる処理
    /// </summary>
    /// <param name="other"></param>
    private void OnTriggerStay(Collider other)
    {
        // 死んでたら何もしない
        if (this.IsDeath || this.IsEnd)
        {
            return;
        }
        // 相手の戦士に当たった時の処理
        if (other.tag == "Warrior")
        {
            if (other.GetComponent<Warrior>().IsDeath)
            {
                return;
            }
            if (other.GetComponent<Warrior>().TeamID != this.TeamID)
            {
                if (IsAttack)
                {
                    tempTime += Time.deltaTime;
                    if (tempTime >= AttackInterval)
                    {
                        Attack(other.gameObject);
                        tempTime = 0;
                    }
                    //StartCoroutine("Attack", other.gameObject);
                }
                else
                {
                    IsAttack = true;
                }
            }
        }
        // 敵の塔に当たった時の処理
        else if (other.tag == "Tower")
        {
            if (other.GetComponent<Tower>())
            {
                if (other.GetComponent<Tower>().TeamId != this.TeamID)
                {
                    if (IsAttack)
                    {
                        tempTime += Time.deltaTime;
                        if (tempTime >= AttackInterval)
                        {
                            Attack(other.gameObject);
                            tempTime = 0;
                        }
                        //StartCoroutine("Attack", other.gameObject);
                    }
                    else
                    {
                        IsAttack = true;
                    }
                }
            }
        }
    }

    // 敵の塔とか戦士から離れたときの処理
    private void OnTriggerExit(Collider other)
    {
        if (other.tag == "Warrior")
        {
            if (other.GetComponent<Warrior>().TeamID != this.TeamID)
            {
                IsAttack = false;
                tempTime = 0;

            }
        }
        else if (other.tag == "Tower")
        {
        }
    }

    /// <summary>
    /// 戦士を動かす処理
    /// </summary>
    /// <param name="point">動かす座標</param>
    public void MoveWarrior(Vector3 point)
    {
        if (IsDeath || IsEnd)
        {
            return;
        }
        this.agent.SetDestination(point);
    }


    /// <summary>
    /// 敵戦士や塔を攻撃する処理
    /// </summary>
    public void Attack(GameObject target)
    {
        // 死んでたら何もしない
        if (IsDeath || IsEnd)
        {
            return;
        }
        //while (true)
        //{
        // 敵の方向見る
        //Quaternion targetRotation = Quaternion.LookRotation(target.transform.position - this.transform.position);
        //transform.rotation = Quaternion.Slerp(this.transform.rotation, targetRotation, Time.deltaTime);
        this.agent.SetDestination(this.transform.position);
        // 攻撃
        target.GetComponent<ICharacter>().Damage(warriorStatus.Atk);
        WarriorAnimator.SetTrigger("Attack");
        // 1秒待つ
        //yield return new WaitForSeconds(1);
        //}

    }

    /// <summary>
    /// 敵戦士からダメージ受ける処理
    /// </summary>
    /// <param name="damage">ダメージ量</param>
    public void Damage(int damage)
    {
        this.HitPoint -= damage * (100 - warriorStatus.Def) / 100;
        if (this.HitPoint <= 0)
        {
            Death();
        }
    }

    /// <summary>
    /// 賢者から回復受ける処理
    /// </summary>
    /// <param name="repair">回復量</param>
    public void Repair(int repair)
    {
        // 死んでたら回復されない
        if (IsDeath || IsEnd)
        {
            return;
        }
        this.HitPoint += repair;
        if (this.HitPoint >= 100)
        {
            HitPoint = 100;
            return;
        }
    }

    /// <summary>
    /// 死んだときの処理
    /// </summary>
    public void Death()
    {
        // 死んだ
        IsDeath = true;
        this.GetComponent<NavMeshAgent>().SetDestination(this.transform.position);
        //死亡アニメーション再生
        WarriorAnimator.SetBool("isDeath", true);
    }

    /// <summary>
    /// リスポーン処理
    /// </summary>
    public void Respawn()
    {
        IsDeath = false;
        // 復活アニメーション
        WarriorAnimator.SetBool("isDeath", false);
        // HPを半分回復
        this.HitPoint = 50;
        this.GetComponent<NavMeshAgent>().enabled = true;
    }

}

管理クラス

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

public class WarriorManager : MonoBehaviour
{
    /// <summary>
    /// 子オブジェクトにある戦士
    /// </summary>
    public List<GameObject> Warriors;
    /// <summary>
    /// 子オブジェクトにある戦士のNavMeshAgent
    /// </summary>
    public List<Warrior> WarriorScripts;

    // Start is called before the first frame update
    void Start()
    {
        // 子オブジェクトの戦士と,そのNavMeshAgentを取得
        foreach (Transform childObject in this.transform)
        {
            Warriors.Add(childObject.gameObject);
            WarriorScripts.Add(childObject.GetComponent<Warrior>());
        }
    }

    // Update is called once per frame
    void Update()
    {
        
    }


    /// <summary>
    /// 移動処理(賢者から呼び出し)
    /// </summary>
    /// <param name="point">戦士が移動する場所の座標</param>
    public void DecidePoint(Vector3 point)
    {
        foreach (Warrior warrior in WarriorScripts)
        {
            warrior.MoveWarrior(point);
        }
    }

    /// <summary>
    /// ゲームが終わった時の処理
    /// </summary>
    public void GameEnd()
    {
        foreach (Warrior warrior in WarriorScripts)
        {
            warrior.IsEnd = true;
        }
    }
}

ステータス管理

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu]
public class WarriorStatusData : ScriptableObject
{
    public float AttackInterval = 1;
    public int HP = 100;
    //ListステータスのList
    public List<WarriorStatus> WarriorStatusList = new List<WarriorStatus>();
}

/// <summary>
/// 戦士のパラメータのクラス
/// </summary>
[System.Serializable]
public class WarriorStatus
{
    /// <summary>
    /// 攻撃型などの戦士のタイプ
    /// </summary>
    public string Name = "タイプ";
    /// <summary>
    /// 体力,攻撃力,防御力
    /// </summary>
    public int Atk = 15, Def = 15;
    public Material[] WarriorMaterials;
}


バトル関連

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

public class GameMaster : MonoBehaviour
{
    /// <summary>
    /// ゲームがスタートしたか否か
    /// </summary>
    public bool IsGameStart;
    /// <summary>
    /// ゲームが終わったか否か
    /// </summary>
    public bool IsGameEnd;
    /// <summary>
    /// 1P2Pの戦士の数
    /// </summary>
    public int WarriorNum1, WarriorNum2;
    /// <summary>
    /// 1P2Pの塔の残りの数
    /// </summary>
    public int TowerNum1, TowerNum2;
    /// <summary>
    /// 1P2Pの塔の残りHP
    /// </summary>
    public int TowerRestHP1, TowerRestHP2;
    /// <summary>
    /// 勝利したプレイヤー
    /// 0は引き分け
    /// </summary>
    public int Winner = 0;

    /// <summary>
    /// 1P2Pのwisemanインスタンス
    /// </summary>
    [SerializeField]
    private Wiseman wiseman1 = default, wiseman2 = default;

    [SerializeField]
    private EndText endText;
    [SerializeField]
    private Result result;

    // Start is called before the first frame update
    void Start()
    {
        // 一応動的に取得できるようにする
        wiseman1 = GameObject.FindGameObjectWithTag("Player1").GetComponent<Wiseman>();
        wiseman2 = GameObject.FindGameObjectWithTag("Player2").GetComponent<Wiseman>();
        // 最初は動けないように
        wiseman1.enabled = false;
        wiseman2.enabled = false;
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    /// <summary>
    /// ゲームスタート処理
    /// </summary>
    public void GameStart()
    {
        IsGameStart = true;

        // プレイアブルにする
        wiseman1.enabled = true;
        wiseman2.enabled = true;
    }

    /// <summary>
    /// ゲームエンド処理
    /// </summary>
    public void GameEnd()
    {
        // 賢者の動きとめる
        wiseman1.enabled = false;
        wiseman2.enabled = false;

        // 戦士の動きとめる
        var warriorManagers = FindObjectsOfType<WarriorManager>();
        foreach (WarriorManager manager in warriorManagers)
        {
            manager.GameEnd();
        }

        // ゲーム終了時のテキスト出す
        endText.Show();

        CountWarrior();
        CheckTowerDamage();
        Winner = Judgement();
        StartCoroutine("ShowResult");
    }

    /// <summary>
    /// リザルト出す
    /// </summary>
    /// <returns></returns>
    IEnumerator ShowResult()
    {
        yield return new WaitForSeconds(2);
        result.ShowResult(WarriorNum1, WarriorNum2, TowerNum1, TowerNum2, Winner);

    }

    /// <summary>
    /// 戦士カウント処理
    /// </summary>
    public void CountWarrior()
    {
        var warriors = FindObjectsOfType<Warrior>();
        foreach (Warrior warrior in warriors)
        {
            // 死んでるか死んでないかで絞込
            if (!warrior.IsDeath)
            {
                // チームIDで絞込
                switch (warrior.TeamID)
                {
                    case 1:
                        WarriorNum1++;
                        break;
                    case 2:
                        WarriorNum2++;
                        break;
                    default:
                        break;
                }
            }
        }
    }

    /// <summary>
    /// 木のダメージカウント処理(塔の本数も数える)
    /// </summary>
    public void CheckTowerDamage()
    {
        var towers = FindObjectsOfType<Tower>();
        foreach (Tower tower in towers)
        {
            // 建っているかどうか
            if(tower.HitPoint > 0)
            {
                // チームIDで分岐
                switch (tower.TeamId)
                {
                    case 1:
                        TowerNum1++;
                        TowerRestHP1 += tower.HitPoint;
                        break;
                    case 2:
                        TowerNum2++;
                        TowerRestHP2 += tower.HitPoint;
                        break;
                    default:
                        break;
                }
            }
        }
    }

    /// <summary>
    /// どっちが勝ったかを判定
    /// 0だったら引き分け
    /// </summary>
    public int Judgement()
    {
        // 塔がより多く建っている方の勝ち
        if (TowerNum1 > TowerNum2)
        {
            return 1;
        }
        else if (TowerNum2 > TowerNum1)
        {
            return 2;
        }
        // 塔の体力が残っていたほうの勝ち
        else if (TowerRestHP1 > TowerRestHP2)
        {
            return 1;
        }
        else if (TowerRestHP2 > TowerRestHP1)
        {
            return 2;
        }
        else
        {
            return 0;
        }
    }
}

「今回企画で話し合った内容」では、アニメーション付きだったので完成後のアニメーション
gyazo.com

Editor上での再生例
https://i.gyazo.com/bb19c285b58141dd82e2250bdcd3b4fc.mp4

Warriorクラスが関係のあるバトル処理


「GGJでUnity3d初挑戦」では結構な物量があったので、断念

*断念しなくてもよくない?
→ここが人によって温度感があるところですが、個人的には「GGJであっても動くものになっていて欲しい」と「自分自身で怪しいものを持ち続けて最後にできませんでした」がつらいのは仕事上わかるので持っていくのをやめました。

*これ書かなくてもよくない?

  • よく「3Dと2Dってz軸座標ぐらいの違いだからいけるっしょ?」などのお話に波及してしまってまずかったため...

上記簡単に見えますが、商業で大規模の場合は人員が分かれているお仕事がなくなってしまうことを懸念したため

大きめだと、Unity後のマージだと以下の3人ぐらいでやったり等々があります。

職種 内容
基盤系エンジニア インターフェースクラスやクラス設計等
トルエンジニア バトル系ロジック
アニメータ 兵士のアニメータ調整

GGJや短期ハッカソンで、意欲旺盛でみんなでやろうぜが多いので上記のような分業はしないことがおおいのですが、
「おしごと」であれば分かれているので「おしごとを奪わない」ように書いてみました....
*スタートアップや立ち上げのふりーらすんの方は、保守よりも立ち上げを重視して、一人でやっている場合はまた別です

そのあとにやったタスク

BGMとサウンドについてアナログで一覧化

f:id:sakuriver:20200206022457j:plain

GGJでは、「遊べるところまで」にフォーカスしているのでメイン画面に注力していました

メインとフロー順に直すと、以下の音楽を最優先

  • ゲームメイン開始演出効果音
  • ゲームメインBGM
  • 命令発動の効果音
  • ゲームが時間切れになったときの効果音

次の音楽は最悪載らなかったら優先度を下げるようにしていました

  • タイトルBGM
  • 決定音(次の画面に進む場合やキャンセルするときに鳴らす用の音になります)


帰宅してからUnityの新バージョンを導入する

gyazo.com


githubの申請が届いていなかったので連絡をする

Blenderは逆にバージョンを下げて、インストールをして確認できたので共有をする

gyazo.com


コーディング規約のテキストファイルを見ながらシステムも合わせた認識合わせ
→継承関連について、ここで見逃す
 ・Unityのシーン命名規則
 ・Unityプロジェクトが自分のローカル環境で開いて確認できるか
 ・gitignoreが自分の環境で動かせなかったので相談→最終的にUnityCollabをあろえさんから提案していただいたので使う方向に
ライセンス関連
 ・素材ファイルのライセンス記入用ファイルを用意
課題管理
 ・デバッグのチケット範囲がGGJ的に大きいのでタイトルを変更(タイトル画面やオプション画面は優先度を下げて遊ぶ部分に集中)
情報共有
 ・デバッグ用のテストファイル上げ場所の相談→ドライブのURLを用意してもらったので、アートとサウンドをシートに一覧化
gyazo.com

gyazo.com

 *アートのお2人と命名規則を相談して、Unity化するのでプログラム側に合わせてもらう話
 ・グラフィックシートの改修
  *列の追加
   グラフィックシート
   素材ファイル→Unityへのインポート作業状態確認
 ・サウンド関連
  *世界観が並行作業だったので、組み込み用にメイン画面を中心にBGMとSEファイル仮用意
 ・プレファブ化
  ・塔オブジェクト
   *スクショを張り付ける
  ・結果ダイアログ  
   Unity上でレイアウトの仮組スクショを撮影して、アートメンバーとやり取り
   
gyazo.com

gyazo.com

 画面遷移のモックプログラム(タイトル→メイン、 タイトル→操作説明)
 決定音の仮組込
 画面レイアウトについて、組みあがっていなかったものを適宜相談
*最終日は、Part3へ続く