@nqounetです。
前回の振り返り
前回は、プレイヤーの状態を1つのオブジェクトにまとめて保存するPlayerSnapshotクラスを作成しました。is => 'ro'で不変性を保証し、カプセル化を維持しながら状態を保存できるようになりました。
前回作成したもの
PlayerSnapshotクラス — すべての属性がis => 'ro'で不変save_snapshotメソッド — Playerの状態からスナップショットを作成
解決できたこと、できていないこと
解決できたこと:
- 参照コピーの罠を回避
- カプセル化を維持した状態保存
- 不変性の保証
まだできていないこと:
- スナップショットから復元する機能
- 複数のスナップショット管理
- 自動的な保存
今回は、「スナップショットから復元する機能」を実装します。
今回のゴール
今回のゴールは、保存したスナップショットから、プレイヤーの状態を復元することです。
restore_from_snapshotメソッドを実装する- セーブ→ダメージ→ロードの流れを完成させる
- 復元が正しく動作することを確認する
これで、ゲームのセーブ・ロード機能の基本が完成します。
restore_from_snapshotメソッドの設計
スナップショットから状態を復元する方法を考えましょう。
sequenceDiagram
participant P as Player
participant S as PlayerSnapshot
Note over P: HP:0, 金:50, アイテム:[薬草, 毒消し]
Note over S: HP:70, 金:50, アイテム:[薬草]
P->>P: restore_from_snapshot(snapshot)
P->>S: hp(), gold(), position(), items()
S-->>P: 70, 50, '森', ['薬草']
P->>P: 属性を更新
Note over P: HP:70, 金:50, アイテム:[薬草]
restore_from_snapshotメソッドは、スナップショットの値を読み取り、自分の属性を更新します。
なぜPlayerクラスにメソッドを追加するのか
復元機能をPlayerクラスに追加する理由は、カプセル化を維持するためです。
1
2
3
4
5
6
7
| # 悪い例:外部で属性を直接更新(カプセル化の破壊)
$player->hp($snapshot->hp);
$player->gold($snapshot->gold);
$player->position($snapshot->position);
# 良い例:Playerクラスのメソッドで復元(カプセル化を維持)
$player->restore_from_snapshot($snapshot);
|
外部から属性を直接更新すると、Playerクラスの内部構造を知る必要があります。もし将来、属性が増えたり、復元時に特別な処理が必要になったりしたら、外部のコードをすべて修正しなければなりません。
restore_from_snapshotメソッドを用意することで、復元の責任をPlayerクラスに集約できます。
コード例1:restore_from_snapshotメソッドの実装
それでは、Playerクラスにrestore_from_snapshotメソッドを追加しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
| # Perl v5.36 以降
# 外部依存: Moo
use v5.36;
package Player {
use Moo;
use v5.36;
has hp => (
is => 'rw',
default => 100,
);
has gold => (
is => 'rw',
default => 0,
);
has position => (
is => 'rw',
default => '町',
);
has items => (
is => 'rw',
default => sub { [] },
);
sub take_damage ($self, $amount) {
$self->hp($self->hp - $amount);
if ($self->hp < 0) {
$self->hp(0);
}
}
sub earn_gold ($self, $amount) {
$self->gold($self->gold + $amount);
}
sub move_to ($self, $location) {
$self->position($location);
}
sub is_alive ($self) {
return $self->hp > 0;
}
sub show_status ($self) {
say "HP: " . $self->hp;
say "所持金: " . $self->gold . "G";
say "位置: " . $self->position;
my $items = $self->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
items => [$self->items->@*], # 新しい配列リファレンスを作成(参照コピー対策)
);
}
sub restore_from_snapshot ($self, $snapshot) {
$self->hp($snapshot->hp);
$self->gold($snapshot->gold);
$self->position($snapshot->position);
$self->items([$snapshot->items->@*]); # 新しい配列リファレンスを作成(参照コピー対策)
}
};
|
restore_from_snapshotメソッドは、スナップショットを受け取り、その値で自分の属性を更新します。
実装はシンプルですが、重要なポイントがあります。
ポイント1:スナップショットの読み取り
スナップショットの属性はis => 'ro'なので、読み取りしかできません。
1
2
| $snapshot->hp; # OK: 読み取り
$snapshot->hp(100); # エラー: 書き込みはできない
|
これは正しい設計です。スナップショットは「過去の記録」なので、読み取ることはできても書き換えることはできません。
ポイント2:Playerの属性は書き込み可能
一方、Playerの属性はis => 'rw'なので、読み書きできます。
1
| $self->hp($snapshot->hp); # OK: 自分の属性は更新できる
|
これも正しい設計です。Playerは「今遊んでいるゲーム」なので、状態が変化します。
ポイント3:参照のコピー(items)
itemsは配列リファレンスなので、特に注意が必要です。ここは参照を持つデータ構造を扱う際の重要なポイントです。
なぜそのまま代入してはいけないのか?
1
2
| # 悪い例:参照先を共有してしまう
$self->items($snapshot->items);
|
Perlの配列リファレンスは、「データそのもの」ではなく「データの置き場所(住所)」を指しています。
もし上記のようにそのまま代入すると、PlayerとSnapshotが同じ配列(同じ住所)を共有してしまいます。
これの何が問題かというと、復元した後でアイテムを使ったときに、スナップショットの中身まで消えてしまうのです。
悪い例の動作イメージ:
- 復元直後:
PlayerもSnapshotも、同じ ref_A(中身は「薬草」)を見ている。 - プレイヤーが薬草を使う(削除する)。
ref_Aの中身が空になる。Snapshotはまだ ref_A を見ているので、スナップショットの薬草も消えている!
graph TD
subgraph "悪い例:参照の共有(エイリアス)"
P[Player] -->|items| RefA[配列 ref_A]
S[Snapshot] -->|items| RefA
RefA --> Content["[ '薬草' ]"]
end
style RefA fill:#f99
これでは、「セーブデータからやり直そうとしたら、セーブデータのアイテムも空になっていた」という最悪のバグになります。
正しいやり方:デリファレンスして新しい配列を作る
これを防ぐために、中身を取り出して、新しい配列(新しい住所)に移し替える必要があります。
1
2
| # 良い例:新しい配列リファレンスを作成(コピー)
$self->items([$snapshot->items->@*]);
|
$snapshot->items->@* で配列の要素を一度すべてリスト(バラバラの値)として展開し、[...] でそれを新しい無名配列リファレンスに格納しています。
良い例の動作イメージ:
- 復元時:
Snapshotの ref_A の中身(薬草)をコピーして、新しい ref_B を作る。 Player は ref_B を見る。- プレイヤーが薬草を使う(
ref_Bから削除)。 ref_B は空になるが、ref_A(スナップショット)は無傷のまま。
graph TD
subgraph "良い例:独立したコピー"
S[Snapshot] -->|items| RefA[配列 ref_A]
RefA --> ContentA["[ '薬草' ]"]
P[Player] -->|items| RefB[配列 ref_B]
RefB --> ContentB["[ '薬草' ]"]
end
style RefA fill:#9f9
style RefB fill:#9f9
PlayerとSnapshotが独立した配列を持つことで、安全にゲームを進めることができます。
save_snapshotとrestore_from_snapshotの対称性
2つのメソッドを並べて見てみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # 保存:Playerの状態 → PlayerSnapshot
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
items => [$self->items->@*],
);
}
# 復元:PlayerSnapshot → Playerの状態
sub restore_from_snapshot ($self, $snapshot) {
$self->hp($snapshot->hp);
$self->gold($snapshot->gold);
$self->position($snapshot->position);
$self->items([$snapshot->items->@*]);
}
|
保存は、自分の属性を読み取ってスナップショットを作成します。
復元は、スナップショットの属性を読み取って自分の属性を更新します。
この対称性が美しいですね!
graph LR
A["Player
hp, gold, position"] -->|"save_snapshot()"| B["PlayerSnapshot
hp, gold, position"]
B -->|"restore_from_snapshot()"| A
style B fill:#9f9
コード例2:セーブ→ダメージ→ロードのデモ
それでは、完全なセーブ・ロード機能を動かしてみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
| # Perl v5.36 以降
# 外部依存: Moo
use v5.36;
package PlayerSnapshot {
use Moo;
has hp => (
is => 'ro',
required => 1,
);
has gold => (
is => 'ro',
required => 1,
);
has position => (
is => 'ro',
required => 1,
);
has items => (
is => 'ro',
required => 1,
);
};
package Player {
use Moo;
has hp => (
is => 'rw',
default => 100,
);
has gold => (
is => 'rw',
default => 0,
);
has position => (
is => 'rw',
default => '町',
);
has items => (
is => 'rw',
default => sub { [] },
);
sub take_damage ($self, $amount) {
$self->hp($self->hp - $amount);
if ($self->hp < 0) {
$self->hp(0);
}
}
sub earn_gold ($self, $amount) {
$self->gold($self->gold + $amount);
}
sub move_to ($self, $location) {
$self->position($location);
}
sub is_alive ($self) {
return $self->hp > 0;
}
sub add_item ($self, $item) {
push $self->items->@*, $item;
}
sub remove_item ($self, $item) {
my @new = grep { $_ ne $item } $self->items->@*;
$self->items(\@new);
}
sub show_status ($self) {
say "HP: " . $self->hp;
say "所持金: " . $self->gold . "G";
say "位置: " . $self->position;
my $items = $self->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
items => [$self->items->@*], # 新しい配列リファレンスを作成(参照コピー対策)
);
}
sub restore_from_snapshot ($self, $snapshot) {
$self->hp($snapshot->hp);
$self->gold($snapshot->gold);
$self->position($snapshot->position);
$self->items([$snapshot->items->@*]); # 新しい配列リファレンスを作成(参照コピー対策)
}
};
# ゲームループのデモ
my $player = Player->new;
say "=== ゲーム開始 ===";
$player->show_status;
say "森へ移動...";
$player->move_to('森');
$player->show_status;
say "スライムと戦闘!";
$player->take_damage(30);
say "30のダメージを受けた!";
$player->show_status;
if ($player->is_alive) {
say "スライムを倒した!";
$player->earn_gold(50);
say "50Gを手に入れた!";
$player->add_item("薬草");
say "薬草を手に入れた!";
$player->show_status;
}
# セーブポイント
say "=== セーブポイント ===";
my $snapshot = $player->save_snapshot;
say "状態を保存しました";
say "保存された状態:";
{
say "HP: " . $snapshot->hp;
say "所持金: " . $snapshot->gold . "G";
say "位置: " . $snapshot->position;
my $items = $snapshot->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
say "洞窟へ移動...";
$player->move_to('洞窟');
$player->add_item("毒消し草");
say "毒消し草を手に入れた!";
$player->show_status;
say "ドラゴンと戦闘!";
$player->take_damage(80);
say "80のダメージを受けた!";
$player->show_status;
# スナップショットの内容は変わっていないことを確認
say "=== スナップショットの確認 ===";
say "保存された状態は変わっていない:";
{
say "HP: " . $snapshot->hp;
say "所持金: " . $snapshot->gold . "G";
say "位置: " . $snapshot->position;
my $items = $snapshot->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
if (!$player->is_alive) {
say "=== GAME OVER ===";
say "セーブポイントから復元します...";
say "";
# スナップショットから復元
$player->restore_from_snapshot($snapshot);
say "=== 復元完了 ===";
$player->show_status;
if ($player->is_alive) {
say "プレイヤーは復活しました!";
say "ゲームを続行できます。";
}
}
|
実行結果は以下のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
| === ゲーム開始 ===
HP: 100
所持金: 0G
位置: 町
所持品: なし
森へ移動...
HP: 100
所持金: 0G
位置: 森
所持品: なし
スライムと戦闘!
30のダメージを受けた!
HP: 70
所持金: 0G
位置: 森
所持品: なし
スライムを倒した!
50Gを手に入れた!
薬草を手に入れた!
HP: 70
所持金: 50G
位置: 森
所持品: 薬草
=== セーブポイント ===
状態を保存しました
保存された状態:
HP: 70
所持金: 50G
位置: 森
所持品: 薬草
洞窟へ移動...
毒消し草を手に入れた!
HP: 70
所持金: 50G
位置: 洞窟
所持品: 薬草, 毒消し草
ドラゴンと戦闘!
80のダメージを受けた!
HP: 0
所持金: 50G
位置: 洞窟
所持品: 薬草, 毒消し草
=== スナップショットの確認 ===
保存された状態は変わっていない:
HP: 70
所持金: 50G
位置: 森
所持品: 薬草
=== GAME OVER ===
セーブポイントから復元します...
=== 復元完了 ===
HP: 70
所持金: 50G
位置: 森
所持品: 薬草
プレイヤーは復活しました!
ゲームを続行できます。
|
完璧に動きました!ドラゴンに負けた後、森でのセーブポイントに戻り、ゲームを続行できます。
セーブ・ロードの流れを可視化
今回実装したセーブ・ロードの仕組みを、時系列で可視化してみましょう。
flowchart TB
A["ゲーム開始
HP:100 / 所持金:0G / 位置:町"] --> B["森で戦闘
HP:70 / 所持金:50G / 位置:森"]
B --> C["save_snapshot()
スナップショット作成"]
C --> D["洞窟へ移動
HP:70 / 所持金:50G / 位置:洞窟"]
D --> E["ドラゴンと戦闘
HP:0 → ゲームオーバー"]
E --> F["restore_from_snapshot()
スナップショットから復元"]
F --> G["復元完了
HP:70 / 所持金:50G / 位置:森"]
G --> H["ゲーム続行可能"]
style C fill:#9f9
style F fill:#9f9
style E fill:#f99
この流れで、ゲームのセーブ・ロード機能が実現できました。
設計の利点
今回の設計には、いくつかの重要な利点があります。
1. カプセル化の維持
復元の処理がPlayerクラスの中に隠蔽されているため、外部のコードは内部実装を知る必要がありません。
1
2
| # 内部実装を知らなくても使える
$player->restore_from_snapshot($snapshot);
|
2. 将来の拡張に強い
もし将来、Playerクラスに新しい属性(例えばlevelやexperience)を追加しても、save_snapshotとrestore_from_snapshotの中を修正するだけで済みます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # 将来の拡張例
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
items => [$self->items->@*],
level => $self->level, # 追加
experience => $self->experience, # 追加
);
}
sub restore_from_snapshot ($self, $snapshot) {
$self->hp($snapshot->hp);
$self->gold($snapshot->gold);
$self->position($snapshot->position);
$self->items([$self->items->@*]);
$self->level($snapshot->level); # 追加
$self->experience($snapshot->experience); # 追加
}
|
外部のコードは変更不要です。これが、カプセル化のメリットです。
3. 責任の明確化
状態の保存・復元の責任がPlayerクラスに集約されています。
graph TB
subgraph "Player クラスの責任"
A["自分の状態を管理する"]
B["状態を保存する
save_snapshot"]
C["状態を復元する
restore_from_snapshot"]
end
D["外部コード"] --> B
D --> C
style A fill:#9f9
style B fill:#9f9
style C fill:#9f9
この設計により、「状態の保存・復元に関する処理はPlayerクラスを見ればわかる」という明確さが生まれます。
まだできていないこと
今回の実装で、基本的なセーブ・ロード機能は完成しました。しかし、まだいくつかの課題があります。
- 複数のセーブスロットがない — 1つのスナップショットしか保存できない
- セーブスロットの管理機能がない — どのスナップショットをロードするか選べない
- 自動セーブ機能がない — 手動で
save_snapshotを呼ぶ必要がある
次回は、複数のセーブポイントを管理する仕組みを作ります。GameManagerクラスを導入し、セーブスロット機能を実装していきます。
今回作成した完成コード
以下が今回作成した完成コードです。1つのスクリプトファイルとして動作します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
| # Perl v5.36 以降
# 外部依存: Moo
use v5.36;
package PlayerSnapshot {
use Moo;
has hp => (
is => 'ro',
required => 1,
);
has gold => (
is => 'ro',
required => 1,
);
has position => (
is => 'ro',
required => 1,
);
has items => (
is => 'ro',
required => 1,
);
};
package Player {
use Moo;
has hp => (
is => 'rw',
default => 100,
);
has gold => (
is => 'rw',
default => 0,
);
has position => (
is => 'rw',
default => '町',
);
has items => (
is => 'rw',
default => sub { [] },
);
sub take_damage ($self, $amount) {
$self->hp($self->hp - $amount);
if ($self->hp < 0) {
$self->hp(0);
}
}
sub earn_gold ($self, $amount) {
$self->gold($self->gold + $amount);
}
sub move_to ($self, $location) {
$self->position($location);
}
sub is_alive ($self) {
return $self->hp > 0;
}
sub add_item ($self, $item) {
push $self->items->@*, $item;
}
sub remove_item ($self, $item) {
my @new = grep { $_ ne $item } $self->items->@*;
$self->items(\@new);
}
sub show_status ($self) {
say "HP: " . $self->hp;
say "所持金: " . $self->gold . "G";
say "位置: " . $self->position;
my $items = $self->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
items => [$self->items->@*],
);
}
sub restore_from_snapshot ($self, $snapshot) {
$self->hp($snapshot->hp);
$self->gold($snapshot->gold);
$self->position($snapshot->position);
$self->items([$snapshot->items->@*]);
}
};
# ゲームループのデモ
my $player = Player->new;
say "=== ゲーム開始 ===";
$player->show_status;
say "森へ移動...";
$player->move_to('森');
$player->show_status;
say "スライムと戦闘!";
$player->take_damage(30);
say "30のダメージを受けた!";
$player->show_status;
if ($player->is_alive) {
say "スライムを倒した!";
$player->earn_gold(50);
say "50Gを手に入れた!";
$player->add_item("薬草");
say "薬草を手に入れた!";
$player->show_status;
}
# セーブポイント
say "=== セーブポイント ===";
my $snapshot = $player->save_snapshot;
say "状態を保存しました";
say "保存された状態:";
{
say "HP: " . $snapshot->hp;
say "所持金: " . $snapshot->gold . "G";
say "位置: " . $snapshot->position;
my $items = $snapshot->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
say "洞窟へ移動...";
$player->move_to('洞窟');
$player->add_item("毒消し草");
say "毒消し草を手に入れた!";
$player->show_status;
say "ドラゴンと戦闘!";
$player->take_damage(80);
say "80のダメージを受けた!";
$player->show_status;
# スナップショットの内容は変わっていないことを確認
say "=== スナップショットの確認 ===";
say "保存された状態は変わっていない:";
{
say "HP: " . $snapshot->hp;
say "所持金: " . $snapshot->gold . "G";
say "位置: " . $snapshot->position;
my $items = $snapshot->items;
my $items_str = ($items->@*) ? join(', ', $items->@*) : 'なし';
say "所持品: " . $items_str;
say "";
}
if (!$player->is_alive) {
say "=== GAME OVER ===";
say "セーブポイントから復元します...";
say "";
# スナップショットから復元
$player->restore_from_snapshot($snapshot);
say "=== 復元完了 ===";
$player->show_status;
if ($player->is_alive) {
say "プレイヤーは復活しました!";
say "ゲームを続行できます。";
}
}
|
まとめ
今回は、保存したスナップショットから、プレイヤーの状態を復元する機能を実装しました。
作成したもの:
restore_from_snapshotメソッドで、スナップショットから状態を復元できるようにした- セーブ→ダメージ→ロードの完全な流れを実現した
- カプセル化を維持しながら、復元機能を実装した
確認できたこと:
save_snapshotとrestore_from_snapshotの対称性- スナップショットの不変性(
is => 'ro')による安全性 - カプセル化を維持した状態管理の実現
設計の利点:
- 将来の拡張に強い設計
- 責任の明確化(状態管理はPlayerクラスの責任)
- 外部コードがPlayerの内部実装を知る必要がない
基本的なセーブ・ロード機能が完成しました!次回は、複数のセーブスロットを管理する仕組みを作ります。
次回予告
今回、セーブ・ロード機能の基本が完成しました。
しかし、現状では1つのスナップショットしか保存できません。複数のセーブポイントを管理したり、どのセーブデータをロードするか選んだりする機能がありません。
次回は、複数のセーブポイントを管理するGameManagerクラスを作成します。セーブスロット機能を実装し、複数の状態を保存・管理できるようにします。
第5回のテーマ: セーブデータを管理しよう(履歴機能)
お楽しみに。