【Unity】ゲームデータをテキストファイルから読み込むようにする

Unity

Unityでカードゲームを作成しているのですが、ゲーム内の敵のパラメータなどをユーザが自由に編集できるようにできれば面白いなと思いました。

例えば、「ちょっと難易度が低すぎるな」とユーザが感じれば、敵のHPのパラメータを下げるといった感じです。

バランスは崩壊してしまうかもしれませんが、所詮は個人で作成しているゲームなので、そのへんの調整はユーザに任せてもいいのではないかと思います。

そこで、ゲームデータをすべてテキストファイルから読み込むという仕組みを作成してみました。(ユーザは、対象のテキストファイルをメモ帳とかで編集できる)

今回作成したソース

作成したソースのAssets以下をサンプルとして以下にアップロードしてみました。

https://unitygame.slavesystems.com/game/gamedata-text-sample.zip

仕様

  • Assets\GameData\[データの種類を表すフォルダ]にゲームのデータをいれる
  • ゲームデータのテキストファイルは、「項目名:値」という形式で記載していく(次項参照)
  • 値が複数値のときはカンマ区切りで値を区切る

呼び出し方

サンプルとして、プレイヤーとか敵のステータスとかを管理するPlayerというクラスをテキストから作成できるようにしています。

呼び出し方は以下のとおりです。

※引数にはファイル名(拡張子なし)を指定する

Player p = LoadManager.createPlayer("スライム");

次項以降で詳しい説明を記載していきたいと思います。

CSVファイルの一括読み込みはオンデマンドロードにならない

作成にあたっての前提をいくつか記載しておこうと思います。

そもそも、テキストから敵データを読み込むということをせずに、敵のデータを編集できる画面機能を作成するのがベストだと思います。

ただ、テストなどのことも考えると、本編以外のところに労力をかけたくないと思ったので、今回のようにユーザにテキストを変更してもらう形式としました。

またテキストは下記のような形式としています。

「項目名:値」という形式で記載していく形で、1ファイルに1データを管理するというルールにしています。

CSVファイルなどに全データを書き込んで、一括で読み込めばいいとも思ったのですが、それだとゲーム開始時のロード時間が長くなりすぎてしまいます。

scriptableobjectなどは対策として、必要なときだけデータを読み込む(オンデマンド)となっているらしいので、今回作成するものも同様にします。

そのため、1ファイルに全データを書き込むということをしておりません。

今回作成したクラスの説明

早速ですが作成したソースについて記載して行きたいと思います。

クラスとゲームデータの置き場については以下のような構成としました。

今回作成したソースの前提

  • Assets\GameData\[データの種類を表すフォルダ]にゲームのデータをいれる
  • ビルド後はビルド先のフォルダ直下のGameDataフォルダを読み込む
  • ゲームのデータは「項目名:値」という形式で記載していく
  • 値が複数値のときはカンマ区切りで値を区切る
  • 初めてロードされたときだけテキストから値を取得するようにする

作成したクラスは全5種類で、内容は以下のとおりです。

例としてプレイヤーのデータをテキストから取得できるように作成しました。

LoadManagerクラス→データのリストを保管していて、ゲームデータの参照要求があるとゲームデータを返却する

Playerクラス→呼び出し側のメイン処理が使用するプレイヤーデータ

PlayerDataクラス→テキストファイルのパラメータを保持できるクラス、このデータをもとにPlayerクラスを生成するメソッドも持つ

Entityクラス→ゲームデータの基底クラス、テキストファイルの読み込み処理などの共通メソッドを定義

Buildクラス→ゲームをBuildした時に、GameDataクラスをexeファイルが配置されていた場所にコピーする(ユーザが編集できるようにするため)

次項で詳細なソースの内容を記載していこうと思います。

テキストからゲームデータを読み込む処理

特定の場所にあるテキストデータを読み込んで連想配列(Dictionary)に格納するためのクラスです。

この処理は、どのゲームデータをロードするクラスでも使うので抽象クラスとしておくことにしました。

using System;
using System.IO;
using System.Text;
using UnityEngine;
using System.Collections.Generic;
public abstract class Entity
{

    #if UNITY_EDITOR
        private readonly string ROOT_PATH = Application.dataPath + "/GameData";
    #else
        private readonly string ROOT_PATH = AppDomain.CurrentDomain.BaseDirectory.TrimEnd('\\') + "/GameData";
    #endif
   
    protected string subFolderPath = "";
    protected Dictionary<string, string> data = new Dictionary<string, string>();
    protected static readonly char ALLAY_DELEMITER = ',';
    public void load(string id)
    {
        const char PARM_DELEMITER = ':';

        StreamReader sr = new StreamReader(
            ROOT_PATH + "/" + subFolderPath + "/" + id + ".txt",
            Encoding.GetEncoding("UTF-8"));
        string line;
        try
        {
            while ((line = sr.ReadLine()) != null)
            {
                string[] splitLine = line.Split(PARM_DELEMITER);
                data.Add(splitLine[0], splitLine[1]);
            }
        }
        catch (Exception e)
        {
            #if UNITY_EDITOR
                UnityEditor.EditorApplication.isPlaying = false;
            #elif UNITY_STANDALONE
                UnityEngine.Application.Quit();
            #endif
        }
        sr.Close();
    }
}

ROOT_PATHという変数は、ゲームデータが保存されているフォルダを格納する想定で、デバッグ時とビルド時でパスが異なるので「#if UNITY_EDITOR」で分岐させています。

Assets\GameData\[データの種類を表すフォルダ]にゲームのデータをいれる

上記が仕様ですが、subFolderPathという変数に「データの種類を表すフォルダ」を格納する想定です。これは具象クラスで設定します。

エラーが発生した場合は、ゲームを続行できないと思うので終了するようにしています。(本来ならダイアログとかにエラー内容を表示させるべきだと思いますがサボった)

Entityクラスを継承した具象クラスが以下になります。(プレイヤーデータ取得用のクラス)

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

public class PlayerData : Entity
{
    private int hp;
    private string name;
    private string icon;
    private string money;
    private List<string> cardlist;
    public PlayerData(string id)
    {
        subFolderPath = "PlayerData";
        this.load(id);
        try
        {
            this.hp = int.Parse(data["hp"]);
            this.name = data["name"];
            this.icon = data["icon"];
            this.money = data["money"];
            this.cardlist = new List<string>(data["cardlist"].Split(Entity.ALLAY_DELEMITER));
        }
        catch (Exception e)
        {
            #if UNITY_EDITOR
                    UnityEditor.EditorApplication.isPlaying = false;
            #elif UNITY_STANDALONE
                    UnityEngine.Application.Quit();
            #endif
        }
    }
    public Player create()
    {
        Player p = new Player();
        p.maxhp = this.hp;
        p.name = this.name;
        p.icon = this.icon;
        p.money = this.money;
        p.cardlist = this.cardlist;
        return p;
    }
}

■コンストラクタ PlayerData(string id)

Entityクラス側のLoadメソッドでパラメータの連想配列が返却されるので、これを自クラスの持っているフィールドの格納しています。

■createメソッド public Player create()

ゲームのメイン処理側で使用する想定の「Playerクラス」を作成して返却します。

ちなみに、ゲームのメイン処理側で「PlayerDataクラス」を使用するようにすれば「Playerクラス」は不要だと思います。分けたのはテキストファイルの構造とゲームで使用した構造は違うからです。

例えば、テキストファイル側に持っているのは最大HPだけですが、ゲームが扱うクラスとしては現在のHPも欲しいです。

1つのクラスに纏めてしまうと、テキストファイル側の定義がわからなくなってしまうのでクラスを分けるようにしました。

createメソッドで返却されるゲームが扱うクラス「Player」は以下になります。

using System.Collections.Generic;

public class Player
{
    public int maxhp;
    public int hp;
    public string name;
    public string icon;
    public string money;
    public List<string> cardlist;
    // Start is called before the first frame update
    void Start()
    {
        
    }

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

今はフィールドを定義しているだけですが、ここに「攻撃する」とか色々メソッドを追加してゲームを作っていく想定です。

テキストファイルのオンデマンドロードを実現する

前述したとおり、ゲーム起動時のロード時間を短縮するために、ゲーム内でデータが必要となったときだけテキストをロードするようにします。

その仕組を実現するために作成したクラスが以下です。

using System.Collections.Generic;

public static class LoadManager
{
    private static Dictionary<string, PlayerData> playerlist = new Dictionary<string, PlayerData>();
    public static Player createPlayer(string id)
    {
        if (playerlist.ContainsKey(id))
        {
            PlayerData pd = playerlist[id];
            return pd.create();
        }
        else
        {
            PlayerData pd = new PlayerData(id);
            playerlist.Add(id, pd);
            return pd.create();
        }
    }
}

ロードが実行されたplayerクラスをリストで持っていて、まだロードされていないときだけ「new PlayerData(id);」でテキストファイルを読み込む処理を実行しにいきます。

呼び出し方は以下のとおりです。

Player p = LoadManager.createPlayer("スライム");

※createPlayerの引数はファイル名(拡張子なし)を指定する

ユーザが編集できるようにビルド時にフォルダをコピーする

そのままビルドするとGameDataはバイナリファイルになってしまうので、ビルド後にGameDataをゲームが有るフォルダに移動させます。

Editorフォルダの下にビルド時に実行されるクラスを作成します。(OnPostprocessBuildはビルド後に実行されるメソッド)

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using System.IO;

public class Build
{
    [PostProcessBuildAttribute(1)]
    public static void OnPostprocessBuild(BuildTarget target, string path)
    {
        string outFolderPath = System.IO.Path.GetDirectoryName(path) + "/GameData";

        if (Directory.Exists(outFolderPath))
        {
            Directory.Delete(outFolderPath, true);
        }
        FileUtil.CopyFileOrDirectory(Application.dataPath + "/GameData", outFolderPath);
    }
}

コメント

タイトルとURLをコピーしました