32blogby StudioMitsu
renpy14 min read

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

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

renpyvisual-novelgame-devpython
目次

Ren'Pyでステータスシステムを作ろうとすると、ほぼ全員が同じ壁にぶつかる。「セーブデータが壊れる」「ロールバックで値が戻らない」「アップデートしたら古いセーブが読めない」。

この記事では、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 として初期化され、すべてのマイグレーションが順番に実行される。

まとめ

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

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

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

公式リソース: