Ren'Pyでステータスシステムを作ろうとすると、ほぼ全員が同じ壁にぶつかる。「セーブデータが壊れる」「ロールバックで値が戻らない」「アップデートしたら古いセーブが読めない」。
この記事では、Ren'Py 8.5.2 でステータスシステムを セーブ・ロールバック・アップデートに耐える設計 で実装する方法を解説する。入門ガイドで define と default の基本は押さえた前提で進める。
セーブが壊れる5つのパターン
実装に入る前に、Ren'Pyのセーブシステムがどこで壊れるかを知っておこう。コミュニティで繰り返し報告されている典型パターンが5つある。
パターン1: define でステータスを宣言する
# これは壊れる
define player_hp = 100
define は 定数 を宣言する文だ。セーブデータに含まれない。ゲームを再起動すると、途中で変更したHP値は消えて100に戻る。
パターン2: init python 内でインスタンスを作る
init python:
class PlayerStats:
def __init__(self):
self.hp = 100
self.mp = 50
# これは壊れる
player = PlayerStats()
init python ブロックはゲーム起動のたびに再実行される。ここでインスタンスを作ると、ロード時に初期値で上書きされてしまう。
パターン3: クラスの属性変更がロールバックで戻らない
default player = PlayerStats()
label battle:
$ player.hp -= 30
# ← ここでロールバックしても hp は 70 のまま戻らない
Ren'Pyのロールバックは「変数の参照先が変わったか」を追跡する。player.hp -= 30 は player 変数の参照先(オブジェクト自体)を変えていないため、ロールバックの対象にならない。
パターン4: リスト・辞書を define で宣言する
# これは壊れる
define inventory = []
label start:
$ inventory.append("potion")
# セーブ→ロードで inventory は空リストに戻る
パターン1と同じ理由。リストや辞書のように中身が変わるオブジェクトは default で宣言する必要がある。
パターン5: アップデートでクラス構造を変えて古いセーブが読めなくなる
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:正しい使い分け
入門ガイドでも触れたが、ここでもう少し深く掘り下げる。
# 定数 — セーブに含まれない
define MAX_HP = 100
define e = Character("エレン", who_color="#c8ffc8")
# 変数 — セーブに含まれる
default player_hp = 100
default inventory = []
default game_flags = {}
| 文 | セーブ対象 | ロールバック対象 | 用途 |
|---|---|---|---|
define | No | No | キャラクター定義、設定値、上限定数 |
default | Yes | Yes | HP、アイテム、フラグ、ゲーム状態 |
判断基準はシンプルだ。ゲームプレイ中に値が変わるなら default。変わらないなら define。
init python との関係
init python ブロック内で代入された変数もセーブ対象にならない。
init python:
# クラス定義はここに書く(OK)
class PlayerStats:
def __init__(self):
self.hp = 100
# インスタンス化はここに書かない(NG)
# player = PlayerStats()
クラス定義は init python に書き、インスタンス化は default で行う。これが鉄則だ。
# クラス定義
init python:
class PlayerStats:
def __init__(self):
self.hp = 100
# インスタンス化(セーブ対象になる)
default player = PlayerStats()
ロールバック安全なクラス設計
default でインスタンスを宣言しても、クラスの属性変更はロールバックで戻らない(パターン3)。これを解決するには、クラスの継承元を変える。
renpy.store.object を継承する
Ren'Pyの store 名前空間にある object は、内部的に RevertableObject というクラスだ。これを継承すると、属性の変更がロールバックで追跡される。
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 ブロック内で作られたリストや辞書は自動的にロールバック対応版(RevertableList、RevertableDict)になる。default で宣言する場合も同様だ。
# これらは自動的にロールバック対応になる
default inventory = []
default game_flags = {}
通常のPython組み込み型を明示的に使いたい場合(ロールバック不要なデータなど)は、python_list() や python_dict() を使う。ただし、ほとんどのケースではデフォルトのロールバック対応版を使えばいい。
ステータスシステムを実装する
ここまでの知識を使って、実際に動くステータスシステムを作ろう。
クラス定義
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がマイナスになったり最大値を超えたりすることを防ぐ。
インスタンス化と使用
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
ステータス表示スクリーン
画面上にステータスを常時表示するには、スクリーンを定義する。
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の醍醐味は、選択によってキャラクターが成長することだ。
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 文で宣言した 単純な変数 は、ロード時に存在しなければ自動的に初期値が設定される。
# v1.0
default player_hp = 100
# v1.1 で追加 — 古いセーブでも自動的に 80 が設定される
default player_stamina = 80
ただし、これは default で宣言したトップレベル変数に限る。クラスの属性には適用されない。
label after_load でマイグレーションする
クラスの属性を追加・変更した場合は、label after_load でマイグレーション処理を書く。このラベルはセーブデータがロードされるたびに自動実行される。
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 が正しく初期化される。
バージョン追跡パターン
マイグレーションが複数バージョンにまたがる場合は、バージョン番号で管理するとコードが整理しやすい。
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_version は default で宣言しているため、自動補完が効く。古いセーブでは save_version が存在しないため 1 として初期化され、すべてのマイグレーションが順番に実行される。
まとめ
この記事で解説したポイント:
- セーブが壊れる5パターン:
define宣言、init python内インスタンス化、ロールバック非対応、リスト/辞書のdefine、アップデート後の属性欠落 - define vs default: ゲーム中に変わるものは
default、変わらないものはdefine - ロールバック安全クラス:
renpy.store.objectを継承し、defaultでインスタンス化 - ステータスシステム: 上限/下限の強制、スクリーン表示、選択肢による変動
- セーブ互換マイグレーション:
label after_load+hasattr()+ バージョン追跡
Ren'Pyが初めてなら、まず 入門ガイド で環境構築から始めよう。
公式リソース:
- Python Statements —
define/default/init pythonの公式リファレンス - Save, Load, and Rollback — セーブシステムとロールバックの仕組み
- Store Variables — store名前空間の変数一覧
- r/RenPy — コミュニティ(質問・ディスカッション)