1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C#で楽しいゲーム制作 セーブデータ編

Posted at

挨拶

どうも。車輪の再発明大好きお兄さんです
最近自作ゲームにセーブデータ構造を導入してウハウハなので筆をとりました。

今回はセーブデータ・データ構造を作っていきたいと思っています。
私は3年前ぐらいからゲームを作ってきましたが、全部シリアライザとか言う楽なやつで乗り切っていました。(非常にダメでした)

3年前からこれを作っとけばよかったと思います。デバッグもしやすいですし、その他のパラメータも記述しやすい。まあ、細かい機能をたくさん作ってから本当に作りたいものに取り掛かるというのも健康に悪い気がするので仕方なかったですな。(仕方なくない)

大体のプログラムには定義・実装・運用の3段階あると思っています。セーブデータを作るにも定義、実装、運用の段階がありますが、セーブデータそのものが、ほかの機能を作る際の定義になります。この三段階がフラクタル的に続いているということに気を付けていきたいですね。

セーブデータの定義

まずは定義です。
プログラミング・テクニックよりどういう構造がいいかという思想が大切です。
皆さんにはどういうデータ構造の思想がありますか?私にはありません。なので皆さん一番よくみているであろう現代コンピュータのファイル構造をパクってしまいましょう。

ということでこんな感じの定義にします。
[ファイル・フォルダ名]{フォルダの中身}
ハイもう終わりです。後はこれを読み込んだり、書き込んだりする機能を実装するだけですね。
ただ、一つ注意してほしいのが、この構造では同じ名前のファイル・フォルダを同じ場所に設置できません。
例えば同じ名前のキャラクターのデータを保存したい場合とかはインデックスやらIDを用いてファイル名を工夫する必要がありますね。
ちなみに私は[]{}このセットをパックと呼んでいます。ファイルとも少し違う気がするので。

あと、追加の定義も用いちゃいます。結局このセーブデータは一つのテキストファイルに保存するため、書き込みやすい定義が必要ですね。

テキストファイルに書かれた改行は読み込む際に無効化する。改行をデータとして格納したい場合は\nと書く。

これでOKです。
まあ[]はどうするんだとか、{}はどうするんだとかありますけど、私は必要ないと思ったんでこれらのエスケープは作ってないです。
個人でやってるとこういうところが楽なんすよね。あ、ちなみに\\で\は作らなくても大丈夫ですよ多分。ファイルパスに関しては/でも読み込めますし。
エスケープシーケンスいっぱい作ってると頭おかしくなるのでほどほどに

実装

続きまして実装です。
まあ難しいことはないですね。簡単な自然言語処理です。
mohejiという名前のデータを見つけたいとします。

  1. [を見つけたら次の]が見つかるまで進み、間にあった文字列を名前とみなします
  2. これがmohejiでなければ最初に見つかった{からそれを閉じる}が見つかるまでインデックスを進めます。
  3. 次に見つけた名前がmohejiなら{}の中身をデータとして返還。

こんな感じでやればいいですね。
得られた中身のデータにさらにパック[]{}が含まれてもいいですし、そのデータを直接数字に変換して変数に代入してもいいわけです。
あとは ls みたいな機能とか、データを変換して持ってきてくれる関数とかを作って私の実装はこんな感じです。

DataSaver.cs
 class DataSaver
{
    string Data = "";
    static string escapen(string s) 
    {
        s =s.Replace("\n", "");
        s = s.Replace("\r", "");
        s = s.Replace(@"\n", "\n");
      
        return s;
    }
    /// <summary>
    /// 書いてある内容をもらう
    /// </summary>
    /// <param name="escape">\nを変換したりするか</param>
    /// <returns></returns>
    public string getData(bool escape = true) 
    {
        if (escape) 
        {
            return escapen(Data);
        }
        return Data;
    }
    static public DataSaver loadFromPath(string path)
    {
        path = path+".txt";
        try
        {
            using (var reader = new StreamReader(path))
            {
                return new DataSaver(reader.ReadToEnd());
            }
        }
        catch (Exception)
        {
            return new DataSaver();
        }

    }
    public void saveToPath(string path)
    {
        path = path + ".txt";
        try
        {
           
            using (var writer = new StreamWriter(path))
            {
                writer.Write(Data);
            }
            Console.WriteLine("Savekanryou!");

        }
        catch (Exception e)
        {
            Console.WriteLine("savesippai " + e.ToString());
        }
    }
    public DataSaver(string data = "")
    {
        Data = data;
    }
    //今のデータに新しいパックを追加する
    public void packAdd(string name, string data)
    {
        Data += "["+name+"]" + "{" + data + "}\n";
    }
    //こっちのバージョンもある
    public void packAdd(string name, DataSaver d)
    {
        packAdd(name, d.Data);
    }
    /// <summary>
    /// データの中にパックがある前提ね。文字でアンパックする
    /// 
    /// </summary>
    /// <param name="name">対象</param>
    /// <param name="escape"></param>
    /// <param name="nothing">もし中身がなかった時に返す値</param>
    /// <returns></returns>
    public string unPackData(string name,bool escape=true,string nothing="")
    {
        name = "["+name + "]";
        int hiraki = 0;
        bool findname = false;
        int start = -1;
        for (int idx=0; idx < Data.Length; idx++)
        {
            if (hiraki == 0)
            {
                if (start == -1&&!findname)
                {
                    if (idx + name.Length < Data.Length)
                    {
                        if (Data.Substring(idx, name.Length) == name)
                        {
                            findname = true;
                        }
                    }
                }
                else
                {

                }
            }
            if (Data[idx] == '{')
            {
                hiraki += 1;
                if (findname) 
                {
                    findname = false;
                    start = idx + 1;
                }
            }
            if (Data[idx] == '}')
            {
                hiraki -= 1;
                if (start != -1 && hiraki == 0)
                {
                    var dd = idx - start;
                    if (dd == 0)
                    {
                        return nothing;
                    }
                    if (escape)
                    {
                        return escapen(Data.Substring(start, dd));
                    }
                    else 
                    {
                        return Data.Substring(start, dd);
                    }
                    start = -1;
                }
            }
        }
     //   Console.WriteLine("nakattayo " + name);
        return nothing;
    }
    public DataSaver unPackData2(string name,bool escape=true)
    {
        var sou = unPackData(name,escape);
    //    if (sou == "") return null;

        return new DataSaver(sou);
    }
    /// <summary>
    /// floatでアンパックする
    /// </summary>
    /// <param name="name"></param>
    /// <param name="Nan">もし中身がなかった時の返す値</param>
    /// <returns></returns>
    public float unPackData3(string name,float Nan=float.NaN,bool escape=true) 
    {
        var sou = unPackData(name,escape);
        float res = 0;
        if (sou!=null&&float.TryParse(sou,out res)) return res;
        return Nan;
    }
    public List<string> unpackAlldatas(bool escape=true) 
    {
        var res = new List<string>();
        foreach (var a in getAllPacks()) 
        {
          
            res.Add(unPackData(a,escape));
        }
        return res;
    }
    public List<DataSaver> unpackAlldatas2(bool escape=true)
    {
        var res = new List<DataSaver>();
        foreach (var a in getAllPacks())
        {
            res.Add(unPackData2(a,escape));
        }
        return res;
    }
    public List<string> getAllPacks()
    {
        var res = new List<string>();
        int hiraki = 0;
        int start = -1;
        for (int idx = 0; idx < Data.Length; idx++) 
        {
            if (hiraki == 0)
            {
                if (start == -1)
                {
                    if (Data[idx]=='[')
                    {
                        start = idx+1;
                    }
                }
                else
                {
                    if (Data[idx] == ']')
                    {
                        var dd = idx - start;
                        if (dd == 0)
                        {
                            res.Add("");
                        }
                        else
                        {
                            res.Add(Data.Substring(start, dd));
                            
                        }
                    }
                }
            }
            if (Data[idx] == '{')
            {
                hiraki += 1;
                start = -1;
            }
            if (Data[idx] == '}')
            {
                hiraki -= 1;
                start = -1;
            }
        }
        return res;
    }
}


まあこんな感じでいんじゃないでしょうか。
いろいろなシステムの土台になるのであんまり機能を追加してもよくないと思います。

運用

最後に運用です。

どのパックがどのデータに当たるのか。どのデータをどうテキストとして起こすのか。
えー、本来ならスマートに一つの定義を書くだけでこの両者を満たせる実装をすべきだと、思いますが、無理だったんでこんな感じです。

Savedata.cs
  virtual public DataSaver ToSave() 
{
    var res = new DataSaver();
    DataSaver name = new DataSaver();
    res.packAdd("globalrank",globalrank.ToString());
    for(int i=0;i<names.Count;i++) 
    {
        name.packAdd(i.ToString(), names[i]);
    }
    res.packAdd("names",name);

    res.packAdd("status", status);

    DataSaver partyname = new DataSaver();
    for (int i = 0; i < partynames.Count; i++)
    {
        partyname.packAdd(i.ToString(), partynames[i]);
    }
    res.packAdd("partynames", partyname);
    items = items;
    res.packAdd("itemsdata", itemsdata);
    res.packAdd("money", money.ToString());
    {
        var map = md.ToSave();
        res.packAdd("map",map);
    }
    res.packAdd("itemUI", itemUI.ToString());
    var enp = "";
    foreach (var a in enepedia) 
    {
        enp += a + ":";
        
    }
    res.packAdd("enepedia", enp);
    return res;

}
virtual public void ToLoad(DataSaver data)
{
    {
        var d = data.unPackData3("globalrank", -0.5f);
        globalrank = d;
    }
    {
        var d = data.unPackData2("names")?.unpackAlldatas();
        if (d.Count>0) names = d;
    }
    {
        var d = data.unPackData2("status");
        if (d.getData()!="") status = d;
    }
    {
       var d=data.unPackData2("partynames")?.unpackAlldatas();
        if (d.Count>0) partynames = d;
    }
    {
        var d = data.unPackData2("itemsdata");
        if (d.getData() != "") 
        {
            itemsdata = d;
        }
    }
    {
        var d = data.unPackData2("money");
        if (d.getData() != "")
        {
            money = int.Parse(d.getData());
        }
    }
    {
        var d = data.unPackData2("map");
        if (d.getData() != "")
        {
            md.ToLoad(d);
        }
    }
    {
        var d = data.unPackData2("itemUI");
        if (d.getData() != "")
        {
            itemUI = Boolean.Parse(d.getData());
        }
    }

    {
        var d = data.unPackData("enepedia");
        var s=new List<string>();
        foreach (var a in d.Split(':')) 
        {
            if(a!="")s.Add(a);
        }
        enepedia = s;
    }
}

まあ、私のゲームのセーブデータなんでいろいろわからない変数が多いと思いますが、とにかくこの二つの関数でテキスト<->データと変換する訳です。
いちいちパックを書くのも面倒なので : で区切ったり、パックが見つからなかったら何もしなかったりといったルールがありますね。

このルールで書いたり読んだりするセーブデータが次のようになります。

autosave.txt
[globalrank]{-0.5}
[names]{[0]{monika}
[1]{kaito}
[2]{penny}
[3]{tact}
[4]{maniya}
[5]{yuu}
[6]{yoshino}
[7]{sisyo}
}
[status]{[monika]{[AP]{0.3}
[DP]{0}
[EP]{0}
[items]{}
}
[kaito]{[AP]{0.3}
[DP]{0}
[EP]{0}
[items]{}
}
[penny]{[AP]{0}
[DP]{0.3}
[EP]{0}
[items]{}
}
[tact]{[AP]{0.1}
[DP]{0.1}
[EP]{0.1}
[items]{greedyBowl:weightStone:sharpKnife}
}
[maniya]{[AP]{0.3}
[DP]{0}
[EP]{0}
[items]{}
}
[yuu]{[AP]{0.3}
[DP]{0}
[EP]{0}
[items]{}
}
[yoshino]{[AP]{0.1}
[DP]{0.1}
[EP]{0.1}
[items]{}
}
[sisyo]{[AP]{0.1215081}
[DP]{0.09574626}
[EP]{0.08274564}
[items]{}
}
}
[partynames]{[0]{maniya}
[1]{penny}
[2]{yuu}
[3]{kaito}
[4]{yoshino}
[5]{monika}
}
[itemsdata]{[sharpKnife]{[rank]{0}
}
[bigPot]{[rank]{1}
}
[distiller]{[rank]{0}
}
[clothesline]{[rank]{1}
}
[weightStone]{[rank]{1}
}
[goodSleepPillow]{[rank]{0}
}
[telescope]{[rank]{0}
}
[travelDiary]{[rank]{0}
}
[weatherGauge]{[rank]{0}
}
[magicMap]{[rank]{0}
}
[cleanMirror]{[rank]{0}
}
[sacredTriangle]{[rank]{0}
}
[fairDice]{[rank]{0}
}
[wellHammock]{[rank]{1}
}
[stackOfCards]{[rank]{1}
}
[encyclopedia]{[rank]{1}
}
[greedyBowl]{[rank]{0}
}
}
[money]{0}
[map]{[rank]{0}
[x]{505.0863}
[y]{264.1718}
[bosses]{砂漠の蜃気楼:始祖の墓:竜の爪痕:始まりの湖}
}
[itemUI]{True}
[enepedia]{tutitukumo:jotto:}


こんな感じで保存されています。ゲームっぽくていいですね。なんとなく仲間になってるキャラクターの装備品とか、どのくらいボスが残ってるのかが記述されてるのかがわかるんじゃないでしょうか。
また、セーブデータだけでなく、マップのデータ、会話シーンのデータもこの構造で運用しています。

ゲームのセーブデータテクニック

セーブデータに"直接"データを書くのはやめた方がいいと思っています。別でデータベースを作り(今作った構造で作成してもいいです),そのデータベースのどの部分を持っているかのリンクを示す感じでやると楽だと思います。

つまり、RPGならキャラクターの攻撃力とかを直接セーブしない方がいいということです。データベースに基礎ステータスを書いておいて、後はセーブデータに書かれているキャラクターのレベルを参照して読み込む方がデバッグ的にも楽です。力の種で攻撃力が上がる!!とかもってのほかだと思います。絶対でバッグの時めんどくさい。

終わりに

セーブデータなどは世の中に死ぬほど転がっていると思いますが、
直接データを書き換えられる
これだけは担保した方がいいと思います。
また、設定するアイテムやキャラクターのパラメータについてもセーブデータと同様にテキストファイルから読み込めるようにした方がいいですね。(当然ですけど。私は気づくのが遅かった。)
また、ゲームにはそのゲームにあったデータの表し方があるので結局は自作しちゃうのが早いのかなと思います。

こういった制作のインフラ部分は制作のスピードに影響するだけですが、スピードが高まることでクオリティを追求する時間が生まれ、結果的に品質が向上するということは覚えておいた方がいいですね。(n敗)

私の様に既存の構造を理解できない人はぜひ作ってみてください。楽しいですよ。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?