ビールが飲みたい。

ゲーム開発の備忘録などを書きます。

【Unity1週間ゲームジャム お題「あつい」】レリックを獲得した時にパラメータ上昇効果を与える仕組みについて。

はじめに

Unity1週間ゲームジャム お題「あつい」に参加し、無事投稿したのでその時の話を簡単にまとめようと思います。

僕の参加は今回で6回目?7回目?

Unity 1週間ゲームジャム | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

作成したゲームについて

【Mayor's Survive】

Mayor's Survive | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

f:id:eggame:20180910163512g:plain

  • レリック買う
  • 街を作ってお金を稼ぐ
  • 襲ってくる敵を倒す

を繰り返しながらできるだけ長く生存するゲームです。 ゲームジャムに合わない時間泥棒ゲーですが、色々試したい物があったためつい(^^ゞ

Survival Shooter Tutorial - Asset Store

こちらのUnity公式TutorialであるSurvival Shooterを魔改造したゲームでもあります。

動的な値の変更に強そうな無料アセット見つけた!

ちょっと前に流行ったダンジョンメーカーや、最近どハマリしていたSlay the Spireのように、様々なレリックを獲得するとそのゲーム中レリックに応じた多岐にわたる効果が受けられる仕組みがあります。

僕はそういうの大好きなので、自分でどうにか作れないかここのところずっと考えていたのですが、どうも僕の勉強不足で効率のいい発想が浮かばない日々が続いていました。

で、今回の開発中に探し出した主力兵器が下記のアセットです。 assetstore.unity.com

キャラクターのステータスのAttackとかDefenceとか、ゲーム開発者が自由に項目を作ってプレイ中の動作に大きな影響を与えるパラメータの事を”stat”、まとめたものを"stats"などと呼ばれるようですが、このアセットはその辺りの操作を楽に出来るCharacterStatクラスを提供するアセットです。

無料アセットですので、本体の内容はダウンロードしてみてください。 解説動画もあります。 www.youtube.com

ここではそれをどう利用したのかを記述していきます。

レリックを獲得するとどういう流れてステータスが強化されるのか

CharacterStatアセットの中で使うクラス

  • CharacterStat.cs
    floatでBaseValueという基礎値を持つフィールドや、Valueという色んな効果を加味した最終的な値を持つプロパティがあり、StatModifyがリストに追加されたらDirtyフラグが立ち、次にアクセスがあった時にStatModifyの値を読み取りつつValueが再計算されます。
  • StatModify.cs
    CharacterStatのValueに与える効果を定義するクラスです。float Valueで効果量、Typeで加算や乗算など、Orderでどのタイミングでその値を加えるか、SourceでそのStatModifyを作ったオブジェクトをキャッシュし、そのオブジェクトが無くなった時に効果を外す処理が出来るようになります。←StatModifyを作ったオブジェクトが削除の命令を出した時に、オブジェクト自身を元に削除の対象となるStatModifyを探し出すことが出来るようにします。

作ったクラス

  • Relic.cs
    レリックを作るための親クラスです。
    今回はぱぱっとアイテム系を作るため、1アイテム1ScriptableObject(以下SO)方式にしています。
  • PlayerRelic.cs
    プレイヤーに効果を及ぼすRelicSOを作るクラスです。
  • PlayerInventory.cs
    プレイヤーが効果を受け取るためのMonoBehaviourです。Playerに影響を与えるCharacterStatが生えてます。
    PlayerRelicにこのクラスを渡して、CharacterStatを操作します。
  • RelicManager.cs
    獲得したレリックを記憶しておくためのシングルトンクラスです。本来こんな大雑把な管理はしないと思いますが、今回は大急ぎでコードを書いていたため、レリックをここに集めてしまいました。
    レリックがここに登録されると同時に、対象に効果を与える処理が走ります。(ついでに図鑑に登録します。)

レリックを作るコード詳細

Relicクラス

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

namespace U1W.Atsui
{
    public class Relic : ScriptableObject
    {
        public AssetProperty asset;
        public int Cost = 100;
        public int rare = 0;

        static Dictionary<string, Relic> _relicDic = null;
        static public Dictionary<string, Relic> RelicDic
        {
            get { return _relicDic ?? (_relicDic = Resources.LoadAll<Relic>("Relics").ToDictionary(data => data.name, data => data)); }
        }
    }
}

あらゆるレリックを作るための基底クラスです。
AssetPropertyは名前とか説明文とかアイコンとか、どこのアイテムでも使うようなフィールドをまとめたクラスです。

Costで購入金額、rareでレアリティを設定します。
rareはアイテムの出現率に影響を及ぼし、今回は5段階分かれています。
この辺はゲームの仕様に合わせて変わる部分ですね。

作ったレリックをまとめて吸い上げるのも、ここでstaticなDictionaryを作って読み込んでいます。
最初にRelicDicにアクセスがあった時にResourcesフォルダの中のRelicsフォルダ下にある全てのRelicSOをキャッシュし、RelicDic[アセット名]で取得できるようにしています。
これもUnityゲームジャム用取り急ぎコードな感じですが、急いで作る時は重宝しています。

PlayerRelicクラス

using UnityEngine;
using Kryz.CharacterStats;
using UniRx;

namespace U1W.Atsui
{
    [CreateAssetMenu(fileName ="New PlayerRelic", menuName ="U1W/Atsui/PlayerRelic")]
    public class PlayerRelic : Relic
    {
        public int StrengthBonus;
        public int VitalityBonus;
        public int SpeedBonus;
        [Space]
        public float StrengthPercentBonus;
        public float VitalityPercentBonus;
        public float SpeedPercentBonus;

        public void Equip(PlayerInventory c)
        {
            // ボーナス項目が0じゃなかったら効果を与える群
            if (StrengthBonus != 0)
                c.Strength.AddModifier(new StatModifier(StrengthBonus, StatModType.Flat, this));
            if (VitalityBonus != 0)
                c.Vitality.AddModifier(new StatModifier(VitalityBonus, StatModType.Flat, this));
            if (SpeedBonus != 0)
                c.Speed.AddModifier(new StatModifier(SpeedBonus, StatModType.Flat, this));

            if (StrengthPercentBonus != 0)
                c.Strength.AddModifier(new StatModifier(StrengthPercentBonus, StatModType.PercentMult, this));
            if (VitalityPercentBonus != 0)
                c.Vitality.AddModifier(new StatModifier(VitalityPercentBonus, StatModType.PercentMult, this));
            if (SpeedPercentBonus != 0)
                c.Speed.AddModifier(new StatModifier(SpeedPercentBonus, StatModType.PercentMult, this));

            Debug.Log("Equip!");

            // ゲームのメインステートがタイトル画面になったら効果を全て解除する。
            SceneManager.Instance.State
                .FirstOrDefault(x => x == GameState.Title)
                .Subscribe(x => Unequip(c))
                .AddTo(SceneManager.Instance);
        }

        public void Unequip(PlayerInventory c)
        {
            c.Strength.RemoveAllModifiersFromSource(this);
            c.Vitality.RemoveAllModifiersFromSource(this);
            c.Speed.RemoveAllModifiersFromSource(this);
        }
    }
}

プレイヤーに効果を及ぼすレリックを作成するためのクラスです。
RelicクラスからSOを継承していて、[CreateAssetMenu]属性をここでつけることで、UnityのメニューからPlayerRelicクラスの作成が出来るようにしています。

作ったSOはこんな感じでResources/Relics以下にズラッと。

f:id:eggame:20180910174257p:plain

ここではプレイヤーのどの値にどのくらいの効果を及ぼすのかを設定し、Relic獲得時にRelicManagerの操作によってPlayerInventoryにあるCharacterStatフィールドにStatModifyを与えます。

このクラスが多種多様な効果を及ぼすキモで、ショップになにか影響を及ぼすレリックを作りたければclass ShopRelic : Relic、人口増加や仕事で稼げる金額などに効果を与えたければclass CityRelic : Relicなどを作り、RelicManagerに操作してもらうための関数を定義する感じになります。

レリックをプレイヤーに与えるコード詳細

PlayerInventoryクラス

using Kryz.CharacterStats;
using UnityEngine;
using UniRx;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace U1W.Atsui
{
    public class PlayerInventory : MonoBehaviour {

        [SerializeField] PlayerHealth health;
        [SerializeField] PlayerMovement move;
        [SerializeField] PlayerShooting shooting;

        public CharacterStat Strength;
        public CharacterStat Vitality;
        public CharacterStat Speed;

        void Start () {
            Strength = new CharacterStat(shooting.damagePerShot);
            Vitality = new CharacterStat(health.vit);
            Speed = new CharacterStat(move.speed);

            Strength.UpdateValue
                .Subscribe(x => shooting.damagePerShot = (int)x)
                .AddTo(this);

            Vitality.UpdateValue
                .Subscribe(x => health.vit = (int)x)
                .AddTo(this);

            Speed.UpdateValue
                .Subscribe(x => move.speed = x)
                .AddTo(this);
        }
        
#if UNITY_EDITOR
        private void OnGUI()
        {
            GUILayout.BeginArea(new Rect(0, 0, 100, 200));
            {
                using (new GUILayout.VerticalScope(GUI.skin.box))
                {
                    GUILayout.Label("STR: " + Strength.Value);
                    GUILayout.Label("VIT: " + Vitality.Value);
                    GUILayout.Label("AGI: " + Speed.Value);
                }
            }
            GUILayout.EndArea();

        }
#endif
    }
}

プレイヤーのHPや攻撃力、移動速度などを一箇所で参照して値を書き換えられるようにし、CharacterStatの値が更新されたらそれぞれの値を書き換えるためのPlayer GameObjectにくっつけるコンポーネントです。
普段はRelicManagerにプレイヤーに対するRelicリストを持たせるよりこちらに持たせる方が良さそうな気がしますが、ちゃちゃっと表示など作る都合上、今回Inventoryは値を操作するだけのクラスにしました。

Startで各コンポーネントの初期値を元にCharacterStatをnewし、その後はUniRxのReactivePropertyでCharacterStatに作ったUpdateValueにより更新通知がきたら各コンポーネントの値を書き換えてプレイヤーに影響を与えます。

※追記18/9/10 22:30

PlayerInventoryなんてコンポーネントにしていますが、こういう用途なら

public class PlayerStatModifier : MonoBehaviour, IStatModifier
{
}

のようにしてPlayerInventoryを受け取るのではなくIStatModifierを受け取って操作した方がいろいろ汎用性が高いと思われます。
どうですか、お客さん!?(Interface不慣れ)


ここでちょっとCharacterStatの中身の話。

そう、実はCharacterStatはisDirtyがtrueになった後Valueにアクセスがあると計算が行われるため、StatModifyを与えただけじゃDirtyフラグが立つだけになります。

CharacterStatのサンプルではValueをUpdateで読み取っているため大した影響はありませんが、僕は普段値が更新されたら通知して反映されるように作っているため、StatModifyを与えたらすぐ更新して通知してくれないと困ります。

ので、UniRxを利用してCharacterStat.csに以下のような改造を施しました。

// このReactivePropertyを生やす
FloatReactiveProperty _updateValue = new FloatReactiveProperty();
public IReadOnlyReactiveProperty<float> UpdateValue { get { return _updateValue; } }

// 中のコードは書きませんが、
isDiry = true;
// となっている部分の下に
_updateValue.Value = Value;
// を記述して、すぐに更新して通知されるようにしています。

RelicManagerクラス

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

namespace U1W.Atsui
{
    public class RelicManager : Singleton<RelicManager>
    {
        [Serializable]
        public class Data
        {
            public List<string> relics = new List<string>();
        }

        // ListをJsonUtilityで保存するためクラスに入れています。
        public Data relicData = new Data();
        [SerializeField] PlayerInventory player;
        
        public void AddRelic(Relic relic)
        {
            if (relic is PlayerRelic)
            {
                PlayerRelic data = (PlayerRelic)relic;
                data.Equip(player);
            }

            DataHolder.Instance.AddBook(relic);

            relicData.relics.Add(relic.name);
        }

        public void RemoveRelic(Relic relic)
        {
            // 実は今回1プレイが終了するとシーン読み直ししており、
            // 全てのレリックがリセットされる仕様のため、
            // この関数全く使っていません。

            // 実際の運用時には、この場で
            // relicData.relics[relic.name].UnEquip(対象);
            // を呼び出すなり、
            // リストからレリックを削除した時にそのレリックの
            // 効果を外す処理を予め仕込んでいる必要があります。
            relicData.relics.Remove(relic.name);
        }
        
        public void Resume(Data data)
        {
            foreach (var relic in data.relics)
            {
                if(Relic.RelicDic.ContainsKey(relic))
                    AddRelic(Relic.RelicDic[relic]);
            }
        }

        // TODO もっと軽く出来る
        public List<Relic> LootBox(int count = 3)
        {
            Debug.Log("Relic.RelicDic.Count: " + Relic.RelicDic.Count);

            var list = new Dictionary<Relic, int>();
            foreach (var name in Relic.RelicDic.Keys)
            {
                if (!relicData.relics.Contains(name))
                {
                    list.Add(Relic.RelicDic[name], 21 - (Relic.RelicDic[name].rare * 5));
                }
            }
            count = Mathf.Min(count, list.Count);

            if (count <= 0) return null;

            List<Relic> result = new List<Relic>();

            for (var i = 0; i < count; i++) {
                var relic = WeightedRandomizer.From(list).TakeOne();
                result.Add(relic);
                list.Remove(relic);
            }

            return result;
        }
    }
}

Relicを一元管理するシングルトンクラスです。
レリックの追加や削除、ついでに重みを元にランダムでレリックを引っ張り出す関数が作ってあります。(これは機能分けたほうが良いやつ)

ここで重要な部分は

        public void AddRelic(Relic relic)
        {
            if (relic is PlayerRelic)
            {
                PlayerRelic data = (PlayerRelic)relic;
                data.Equip(player);
            }

            DataHolder.Instance.AddBook(relic);

            relicData.relics.Add(relic.name);
        }

というレリックが追加された時の処理で、RelicSOはポリモーフィズムで派生させているため単純に受け取ったRelicからはどうやってもPlayerRericで作った機能が引っ張り出せません。が、

if(relic is PlayerRelic)


で受け取ったレリックがPlayerRelic型かどうかが判断できます。
これを元に(PlayerRelic)relicとすることで型変換を行うと、PlayerRelicとしての機能やフィールドの値が使用できるようになります。

これがShopRelicだったらif(relic is ShopRelic)で、CityRelicだったらif(relic is CityRelic)で判断が出来て、その中に作った関数を使う処理を書けば効果を発揮させる事が出来るわけです。

ついでにDataHolder.Instance.AddBook(relic);で図鑑登録しつつ、レリックリストに追加します。

結果できたもの

f:id:eggame:20180910182039g:plain

うむ、良い感じです。

感想

  • CharacterStatは無料で勉強になる!
  • ちょうど実験していた機能を組み合わせたゲームで、見栄えがちょっと...。っていうかいつも見栄えがちょっと....。
  • UX頑張れるようになりたいですね!

次回も頑張っていきたい。