32blogby Studio Mitsu

Ren'Pyステータス管理:セーブを壊さない設計パターン

Ren'Py 8.5でRPG・TRPGのステータスシステムを安全に実装する方法。define/defaultの使い分け、ロールバック対応クラス設計、セーブ互換マイグレーションまで解説。

by omitsu17 min read
目次

Ren'Pyでセーブに耐えるステータスシステムを作るポイントは3つ。ゲーム中に変わる値は default で宣言する、クラスは init python で定義して default でインスタンス化する、ロールバック対応には renpy.store.object を継承する。

Ren'Pyでステータスシステムを作ろうとすると、ほぼ全員が同じ壁にぶつかる。「セーブデータが壊れる」「ロールバックで値が戻らない」「アップデートしたら古いセーブが読めない」。r/RenPyLemma Soft Forums で繰り返し報告される定番の悩みだ。

この記事では、Ren'Py 8.5.2 でステータスシステムを セーブ・ロールバック・アップデートに耐える設計 で実装する方法を解説する。入門ガイドdefinedefault の基本は押さえた前提で進める。

セーブが壊れる5つのパターン

実装に入る前に、Ren'Pyのセーブシステムがどこで壊れるかを知っておこう。コミュニティで繰り返し報告されている典型パターンが5つある。

パターン1: define でステータスを宣言する

renpy
# これは壊れる
define player_hp = 100

define定数 を宣言する文だ。セーブデータに含まれない。ゲームを再起動すると、途中で変更したHP値は消えて100に戻る。

パターン2: init python 内でインスタンスを作る

renpy
init python:
    class PlayerStats:
        def __init__(self):
            self.hp = 100
            self.mp = 50

    # これは壊れる
    player = PlayerStats()

init python ブロックはゲーム起動のたびに再実行される。ここでインスタンスを作ると、ロード時に初期値で上書きされてしまう。

パターン3: クラスの属性変更がロールバックで戻らない

renpy
default player = PlayerStats()

label battle:
    $ player.hp -= 30
    # ← ここでロールバックしても hp は 70 のまま戻らない

Ren'Pyのロールバックは「変数の参照先が変わったか」を追跡する。player.hp -= 30player 変数の参照先(オブジェクト自体)を変えていないため、ロールバックの対象にならない。

パターン4: リスト・辞書を define で宣言する

renpy
# これは壊れる
define inventory = []

label start:
    $ inventory.append("potion")
    # セーブ→ロードで inventory は空リストに戻る

パターン1と同じ理由。リストや辞書のように中身が変わるオブジェクトは default で宣言する必要がある。

パターン5: アップデートでクラス構造を変えて古いセーブが読めなくなる

renpy
init python:
    class PlayerStats:
        def __init__(self):
            self.hp = 100
            self.mp = 50
            # v1.1 で追加
            self.stamina = 80

v1.0のセーブデータには stamina 属性が存在しない。ロード時に player.stamina を参照するとクラッシュする。


この5つのうち、パターン1と4は default を使えば解決する。パターン3はクラスの継承元を変える必要がある。パターン2と5にはそれぞれ専用の対策がある。順番に見ていこう。

define vs default:正しい使い分け

入門ガイドでも触れたが、ここでもう少し深く掘り下げる。

renpy
# 定数 — セーブに含まれない
define MAX_HP = 100
define e = Character("エレン", who_color="#c8ffc8")

# 変数 — セーブに含まれる
default player_hp = 100
default inventory = []
default game_flags = {}
セーブ対象ロールバック対象用途
defineNoNoキャラクター定義、設定値、上限定数
defaultYesYesHP、アイテム、フラグ、ゲーム状態

判断基準はシンプルだ。ゲームプレイ中に値が変わるなら default。変わらないなら define

init python との関係

init python ブロック内で代入された変数もセーブ対象にならない。

renpy
init python:
    # クラス定義はここに書く(OK)
    class PlayerStats:
        def __init__(self):
            self.hp = 100

    # インスタンス化はここに書かない(NG)
    # player = PlayerStats()

クラス定義は init python に書き、インスタンス化は default で行う。これが鉄則だ。

renpy
# クラス定義
init python:
    class PlayerStats:
        def __init__(self):
            self.hp = 100

# インスタンス化(セーブ対象になる)
default player = PlayerStats()

ロールバック安全なクラス設計

default でインスタンスを宣言しても、クラスの属性変更はロールバックで戻らない(パターン3)。これを解決するには、クラスの継承元を変える。

renpy.store.object を継承する

Ren'Pyの store 名前空間にある object は、内部的に RevertableObject というクラスだ。これを継承すると、属性の変更がロールバックで追跡される。

renpy
init python:
    class PlayerStats(renpy.store.object):
        def __init__(self):
            self.hp = 100
            self.mp = 50
            self.attack = 10
            self.defense = 5

default player = PlayerStats()

label start:
    "勇者のHPは [player.hp] だ。"

    $ player.hp -= 30

    "攻撃を受けた! HPが [player.hp] に下がった。"

    # ここでロールバックすると player.hp は 100 に戻る

renpy.store.object を継承したクラスは、属性の __setattr__ がフックされ、変更がログに記録される。これにより、ロールバック時に属性値が正しく復元される。

リスト・辞書もロールバック対応にする

同様に、init python ブロック内で作られたリストや辞書は自動的にロールバック対応版(RevertableListRevertableDict)になる。default で宣言する場合も同様だ。

renpy
# これらは自動的にロールバック対応になる
default inventory = []
default game_flags = {}

通常のPython組み込み型を明示的に使いたい場合(ロールバック不要なデータなど)は、python_list()python_dict() を使う。ただし、ほとんどのケースではデフォルトのロールバック対応版を使えばいい。

ステータスシステムを実装する

ここまでの知識を使って、実際に動くステータスシステムを作ろう。

クラス定義

renpy
init python:
    class PlayerStats(renpy.store.object):
        def __init__(self, name, hp=100, mp=50, attack=10, defense=5):
            self.name = name
            self.max_hp = hp
            self.hp = hp
            self.max_mp = mp
            self.mp = mp
            self.attack = attack
            self.defense = defense

        def take_damage(self, amount):
            actual = max(0, amount - self.defense)
            self.hp = max(0, self.hp - actual)
            return actual

        def heal(self, amount):
            self.hp = min(self.max_hp, self.hp + amount)

        def use_mp(self, cost):
            if self.mp < cost:
                return False
            self.mp -= cost
            return True

        def is_alive(self):
            return self.hp > 0

上限・下限は max() / min() で強制する。これにより、HPがマイナスになったり最大値を超えたりすることを防ぐ。

インスタンス化と使用

renpy
default player = PlayerStats("勇者", hp=120, attack=15)

label start:
    scene bg room
    with fade

    "冒険が始まる。"
    "[player.name] のHP: [player.hp]/[player.max_hp]"

    menu:
        "敵が現れた!"

        "剣で攻撃する":
            $ damage = player.take_damage(25)
            "反撃を受けた! [damage] ダメージ!"
            jump check_status

        "魔法で攻撃する":
            if player.use_mp(20):
                "魔法で敵を倒した!"
                jump victory
            else:
                "MPが足りない!"
                $ player.take_damage(30)
                jump check_status

label check_status:
    "[player.name] HP: [player.hp]/[player.max_hp]  MP: [player.mp]/[player.max_mp]"

    if not player.is_alive():
        "力尽きた..."
        return

    "冒険は続く。"
    return

label victory:
    "戦いに勝った!"
    return

ステータス表示スクリーン

画面上にステータスを常時表示するには、スクリーンを定義する。

renpy
screen stats_hud():
    frame:
        xalign 1.0
        yalign 0.0
        xpadding 15
        ypadding 10

        vbox:
            spacing 5
            text "[player.name]" size 18
            text "HP: [player.hp]/[player.max_hp]" size 14
            text "MP: [player.mp]/[player.max_mp]" size 14
            text "ATK: [player.attack]  DEF: [player.defense]" size 14

label start:
    show screen stats_hud
    # 以降、ステータスが右上に表示され続ける

選択肢でステータスを変動させる

TRPGやRPGの醍醐味は、選択によってキャラクターが成長することだ。

renpy
default player = PlayerStats("勇者", hp=100)

label training:
    menu:
        "どの訓練をする?"

        "筋力トレーニング":
            $ player.attack += 3
            "攻撃力が [player.attack] に上がった!"

        "防御訓練":
            $ player.defense += 2
            "防御力が [player.defense] に上がった!"

        "瞑想":
            $ player.max_mp += 10
            $ player.mp = player.max_mp
            "最大MPが [player.max_mp] に上がった!"

    jump next_scene

renpy.store.object を継承しているため、これらの属性変更はすべてセーブ・ロールバックの対象になる。

ゲームアップデートと古いセーブの共存

ゲームをリリースした後、ステータスに新しい属性を追加したくなることは必ずある。しかし古いセーブデータにはその属性が存在しないため、そのままではクラッシュする(パターン5)。

default の自動補完

default 文で宣言した 単純な変数 は、ロード時に存在しなければ自動的に初期値が設定される。

renpy
# v1.0
default player_hp = 100

# v1.1 で追加 — 古いセーブでも自動的に 80 が設定される
default player_stamina = 80

ただし、これは default で宣言したトップレベル変数に限る。クラスの属性には適用されない。

label after_load でマイグレーションする

クラスの属性を追加・変更した場合は、label after_load でマイグレーション処理を書く。このラベルはセーブデータがロードされるたびに自動実行される。

renpy
init python:
    class PlayerStats(renpy.store.object):
        def __init__(self):
            self.hp = 100
            self.mp = 50
            self.attack = 10
            self.defense = 5
            # v1.1 で追加
            self.stamina = 80
            self.max_stamina = 80

default player = PlayerStats()

label after_load:
    # v1.1: stamina 属性がない古いセーブへの対応
    if not hasattr(player, "stamina"):
        $ player.stamina = 80
        $ player.max_stamina = 80

    return

hasattr() で属性の存在を確認してから追加する。これで v1.0 のセーブデータをロードしても stamina が正しく初期化される。

バージョン追跡パターン

マイグレーションが複数バージョンにまたがる場合は、バージョン番号で管理するとコードが整理しやすい。

renpy
default save_version = 1

label after_load:
    if save_version < 2:
        # v1.0 → v1.1: stamina 追加
        if not hasattr(player, "stamina"):
            $ player.stamina = 80
            $ player.max_stamina = 80
        $ save_version = 2

    if save_version < 3:
        # v1.1 → v1.2: luck 追加
        if not hasattr(player, "luck"):
            $ player.luck = 5
        $ save_version = 3

    return

save_versiondefault で宣言しているため、自動補完が効く。古いセーブでは save_version が存在しないため 1 として初期化され、すべてのマイグレーションが順番に実行される。

FAQ

セーブ・ロードすると変数が初期値に戻るのはなぜ?

define で宣言していないか確認しよう。define は定数宣言で、セーブデータに含まれない。default に変えればセーブに値が保存される。公式の Python Statements リファレンスも参照。

definedefault は何が違う?

define は定数(セーブ対象外・ロールバック対象外)。default は変数(セーブ対象・ロールバック対象)。ゲームプレイ中に値が変わるなら default、変わらないなら define を使う。

renpy.store.object の継承は必須?

厳密には不要。init python ブロック内で定義したクラスは自動的に RevertableObject が基底クラスになる。ただし明示的に class PlayerStats(renpy.store.object) と書くことで意図が明確になり、将来 .py ファイルに移動した場合のトラブルも防げる。

アップデートで新しいステータスを追加したら古いセーブがクラッシュする。どうすれば?

label after_loadhasattr() チェックを使う。このラベルはセーブデータのロード時に毎回実行されるため、既存オブジェクトに不足している属性を追加できる。複数バージョンにまたがる場合は save_version 変数で管理する。

クラス名を変更しても大丈夫?

大丈夫ではない。Ren'PyはPythonの pickle でセーブデータをシリアライズしており、クラス名とモジュールパスが記録される。クラス名の変更やファイルの移動はpickleがクラスを見つけられなくなり、ロード時にクラッシュする。

ゲーム中にステータスを常時表示するには?

screen を定義し、テキスト補間([player.hp])でステータス変数を参照する。show screen stats_hud でラベル内から表示すれば、値の変更に合わせて自動更新される。

リスト・辞書は自動的にロールバック対応になる?

init python ブロック内や default 宣言で作成した場合は自動対応。Ren'Pyが RevertableListRevertableDict に置き換える。通常のPython組み込み型が必要な場合(まれ)は python_list()python_dict() を使う。

まとめ

この記事で解説したポイント:

  • セーブが壊れる5パターン: define 宣言、init python 内インスタンス化、ロールバック非対応、リスト/辞書の define、アップデート後の属性欠落
  • define vs default: ゲーム中に変わるものは default、変わらないものは define
  • ロールバック安全クラス: renpy.store.object を継承し、default でインスタンス化
  • ステータスシステム: 上限/下限の強制、スクリーン表示、選択肢による変動
  • セーブ互換マイグレーション: label after_load + hasattr() + バージョン追跡

Ren'Pyが初めてなら、まず 入門ガイド で環境構築から始めよう。

公式リソース:

  • Python Statementsdefine / default / init python の公式リファレンス
  • Save, Load, and Rollback — セーブシステムとロールバックの仕組み
  • Store Variables — store名前空間の変数一覧
  • renpy/renpy — Ren'Pyソースコード(ロールバック実装は renpy/revertable/
  • Python pickle — Ren'Pyがセーブデータに使うシリアライズ形式
  • r/RenPy — コミュニティ(質問・ディスカッション)

関連記事: