main() blog

プログラムやゲーム、旅、愛する家族について綴っていきます。

【pyxel】セーブ/ロード機能を実装しよう!

概要

pyxelのゲームにセーブロード機能を実装方法についての紹介です。
pyxelではセーブロードを行う機能は提供されていません。
ですのでプロジェクトで独自に対応する必要があります。
今回はjson形式やpickleというモジュールでのセーブロードの方法について解説します。

テストプロジェクトのソースはgithubの以下のURLで公開しています。

github.com

セーブデータ構成

テストプロジェクトではゲームのセーブデータを想定して以下のデータを定義しています。
ゲームではよくシステムデータとゲームデータを分けてセーブを行ったりしますが、
今回はテスト実装ということもありセーブデータ一つに集約しています。

# セーブデータ.
# システムデータとゲームデータを分けたりするが、ここではまとめて扱う.
class SaveData:
    def __init__(self):
        self._version = 1
        self._option_data = OptionData()
        self._game_data = GameData()
        self._record_data = RecordData()
        pass

# 戦歴などの記録データ.
class RecordData:
    LOG_MAX = 100

    def __init__(self):
        self._play_time = 0

        # ENEMY_BEGIN から ENEMY_END までの討伐記録データ.
        self._enemy_record = {CharacterID(v): RecordEnemyData() for v in range(CharacterID.ENEMY_BEGIN.value, CharacterID.ENEMY_END.value + 1)}

        # ログデータ.
        # テキスト(文字列)の可変長配列(上限は100件).
        self._log_data = []
        pass

# 敵キャラクターの記録データ.
class RecordEnemyData:
    def __init__(self):
        self._kill_count = 0
        pass


# ゲームデータ.
class GameData:
    def __init__(self):
        # CHARA_BEGIN から CHARA_END までのキャラクターデータ.
        # 範囲内の全キャラクターを初期化しておく.
        self._characters = {CharacterID(v): CharacterData(CharacterID(v)) for v in range(CharacterID.CHARA_BEGIN.value, CharacterID.CHARA_END.value + 1)}
        pass

# オプションデータ.
class OptionData:
    MIN_VOLUME = 0
    MAX_VOLUME = 10

    def __init__(self):
        self._volume_se = 5
        self._volume_voice = 5
        self._volume_bgm = 5

        self._language = Language.JP
        self._difficulty = Difficulty.DEFAULT
        pass

# キャラクターデータ.
class CharacterData:
    def __init__(self, chara_id = CharacterID.NONE):
        self._chara_id = chara_id           # キャラクタID.
        self._name = ""
        self._level = 1
        self._exp= 0
        self._hp = 0
        self._mp = 0
        pass

セーブデータの保存先

pyxelの以下の関数でプラットフォームに適した保存先のパスを返してくれます。

pyxel.user_data_dir(vendor, app_name)

vender、app_nameを引数で与えることでそれぞれのフォルダの階層のフォルダが作成されます。
venderを"takezoh"、app_nameを"pyxel_save_load_sample"とした場合、Macでは以下のフォルダが作成されます。

開発中は実行環境直下に保存しても良いと思います。
後述しますが、ブラウザ版ではこちらのパスを使用しても保存できない様なので、別の実装が必要となります。

json

jsonファイルとして出力する方法は以下で行います。

import json
import os

def save_to_file(self, path):
    # ensure directory exists
    dirname = os.path.dirname(path)
    if dirname:
        os.makedirs(dirname, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(self.to_dict(), f, ensure_ascii=False, indent=2)

json形式で出力する際にjson.dump()の関数を使用しますが、dictとしてデータを用意する必要があります。
今回用意したSaveDataクラスをjsonで保存するためにto_dict()という関数を用意し、dict形式のデータを取得できる様に実装を行う必要が出てきます。

   def to_dict(self):
        return {
            "version": self._version,
            "option_data": self._option_data.to_dict(),
            "game_data": self._game_data.to_dict(),
            "record_data": self._record_data.to_dict(),
        }

SaveDataのメンバにOptionDataやGameDataなどを持たせているので、それぞれにもto_dict()を用意する必要があります。
これでdictとしてデータが取得できる様になるのでjson.dump()でファイルに出力することができます。

ロード処理はjson.load()を使用してjsonファイルを読み込みます。
読み込んだdict形式のデータをSaveDataなどに書き戻していきます。

   @staticmethod
    def load_from_file(path):
        if not os.path.exists(path):
            return None
        with open(path, "r", encoding="utf-8") as f:
            d = json.load(f)
            return SaveData.from_dict(d)

SaveDataクラスにfrom_dict()を用意してdict形式のデータを渡して解析してデータに書き戻す処理を実装していきます。
to_dict()と同様にOptionDataクラスなどにもfrom_dict()を実装していく必要があります。

   @staticmethod
    def from_dict(d):
        s = SaveData()
        s._version = int(d.get("version", 1))
        s._option_data = OptionData.from_dict(d.get("option_data", {}))
        s._game_data = GameData.from_dict(d.get("game_data", {}))
        s._record_data = RecordData.from_dict(d.get("record_data", {}))
        return s

これでjsonファイルでセーブロードを行うことができる様になります。

開発中は平文のままでも良いですが、実際にセーブデータとして使用する場合には暗号化などの対応も必要となってくるのでご留意ください。

pickle

pickleというライブラリが用意されているのでこちらを利用してもセーブロードを行うことができます。

これはclassのインスタンスを渡すと勝手にシリアライズしてくれるようで、json形式の様にdict形式での出力や読み込みなどの実装は不要です。

セーブはpickle.dump()の関数を使用します。
引数でSaveDataのオブジェクト(インスタンス)を渡すとメンバすべてがシリアライズされてファイルに保存されます。

import pickle

def save_pickle(self):
    # フォルダが存在しない場合は作成する.
    save_folder = os.path.dirname(self._save_data_pickle_path)
    if not os.path.exists(save_folder):
        os.makedirs(save_folder)

    with open(self._save_data_pickle_path, 'wb') as file:
        pickle.dump(self._save_data, file)
    pass

ロード処理はpickle.load()を使用します。
読み込みが成功した場合はオブジェクトが返ってくるのでそのままSaveDataとして扱うことができます。

def load_pickle(self):
    if not os.path.exists(self._save_data_pickle_path):
        print("No pickle save file to load")
        return

    with open(self._save_data_pickle_path, 'rb') as file:
        loaded = pickle.load(file)
        if loaded is not None:
            self._save_data = loaded
            print(f"Pickle loaded: {self._save_data_pickle_path}")
            print(self._save_data)
        else:
            print("Failed to load pickle save data")
    pass

ブラウザ版

ブラウザ版を作成するにあたり上記の方法ではそのままでは保存できない様でした。
そこでブラウザのローカルストレージの機能を利用してセーブロードの機能を実装します。

Pyodideというブラウザ上でpythonを実行できる環境があり、js(JavaScript)を扱えるモジュールがあるので、localStorage.setImte()という関数でKeyの値と実際のデータを設定することでローカルストレージへの保存が行えます。
サンプルではjson形式に変換し、その値を保存する様にしています。

# `js` モジュールは Pyodide(ブラウザ)環境で提供される.
# ローカル実行(pyxel 実行環境)では存在しないため安全にフォールバックする.
try:
    from js import window
except Exception:
    window = None


def save_local_storage(self):
    if window is not None:
        try:
            data_str = json.dumps(self._save_data.to_dict())
            window.localStorage.setItem("pyxel_save_data", data_str)
            print("Saved to localStorage")
        except Exception as e:
            print(f"localStorage save error: {e}")
    else:
        print("localStorage not available in this environment")
    pass

ロード時はlocalStorage.GetItem()で保存したときのキーの値を指定することで保存されているデータを読み込むことができます。
読み込まれたデータをjson形式で読み込み直して、SaveDataに戻すようにしています。

def load_local_storage(self):
    if window is not None:
        try:
            data_str = window.localStorage.getItem("pyxel_save_data")
            if data_str is not None:
                data = json.loads(data_str)
                self._save_data = SaveData.from_dict(data)
                print("Loaded from localStorage")
            else:
                print("No localStorage save data to load")
        except Exception as e:
            print(f"localStorage load error: {e}")
    else:
        print("localStorage not available in this environment")
    pass

こちらも平文のままとなっているので、実際にセーブデータとして使用する場合には暗号化などの対応もご検討ください。

参考

github.com

note.com