@nqounetです。
前回の振り返り
前回は、セーブデータの安全性を確保するための設計を再確認しました。PlayerSnapshotの不変性(is => 'ro')により、セーブデータが外部から改ざんされることを防ぎ、ゲームの信頼性を高めました。
前回までに確認したこと
PlayerSnapshotの不変性による保護- 外部からの変更試行がすべて失敗すること
- カプセル化の原則の重要性
今回は、セーブスロット機能を強化し、複数のスロットから選んでロードできる仕組みを作ります。
今回のゴール
今回のゴールは、プレイヤーが複数のセーブスロットを使い分けられるようにすることです。
- セーブスロット選択インターフェースを作成する
- 複数スロットの管理デモを実装する
- スロットの上書き機能を追加する
- セーブスロット情報の表示を改善する
これで、実際のゲームのような「スロット1、2、3から選んでセーブ・ロード」という体験が実現できます。
セーブスロットとは
セーブスロットは、複数のセーブデータを保存するための「枠」です。
graph TB
A["プレイヤー"] --> B{"どのスロットに
セーブする?"}
B --> C["スロット 1
安全プレイ用"]
B --> D["スロット 2
冒険用"]
B --> E["スロット 3
最新オートセーブ"]
style C fill:#9f9
style D fill:#99f
style E fill:#ff9
セーブスロットの用途
プレイヤーは、スロットを以下のように使い分けることができます。
- スロット1:安全プレイ用 — ボス戦前など、確実に勝てる状態でセーブ
- スロット2:冒険用 — 新しいエリアに挑戦する前にセーブ
- スロット3:最新オートセーブ — 自動的に最新の状態を保存
現在の実装の課題
現在のGameManagerは、セーブデータを配列に追加していくだけです。
1
2
3
4
5
| sub save_game ($self, $player) {
my $snapshot = $player->save_snapshot;
push @{$self->saves}, $snapshot; # 常に末尾に追加
return scalar @{$self->saves} - 1;
}
|
この実装には、以下の課題があります。
- スロットを選べない — 常に新しいスロットが作られる
- 上書きができない — 同じスロットに保存できない
- スロット数が無限に増える — メモリを圧迫する
これらを解決するために、セーブスロット選択機能を追加します。
コード例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
| # Perl v5.36 以降
# 外部依存: Moo
package GameManager {
use Moo;
use v5.36;
has saves => (
is => 'ro',
default => sub { [] },
);
has auto_save => (
is => 'rw',
default => 1,
);
has max_slots => (
is => 'ro',
default => 3, # 最大3スロット
);
# 新規:スロット番号を指定してセーブ
sub save_game_to_slot ($self, $player, $slot_number) {
if ($slot_number < 0 || $slot_number >= $self->max_slots) {
die "無効なスロット番号: $slot_number (0〜" . ($self->max_slots - 1) . ")\n";
}
my $snapshot = $player->save_snapshot;
$self->saves->[$slot_number] = $snapshot;
return $slot_number;
}
# 既存のsave_gameは、次の空きスロットに保存
sub save_game ($self, $player) {
# 空きスロットを探す
for my $i (0 .. $self->max_slots - 1) {
unless (defined $self->saves->[$i]) {
return $self->save_game_to_slot($player, $i);
}
}
# 空きがなければ、最後のスロットに上書き
return $self->save_game_to_slot($player, $self->max_slots - 1);
}
sub load_game ($self, $player, $slot_number) {
unless ($self->has_save($slot_number)) {
die "セーブデータがありません: スロット $slot_number\n";
}
my $snapshot = $self->saves->[$slot_number];
$player->restore_from_snapshot($snapshot);
}
sub has_save ($self, $slot_number) {
return defined $self->saves->[$slot_number];
}
sub list_saves ($self) {
say "=== セーブデータ一覧 ===";
for my $i (0 .. $self->max_slots - 1) {
if ($self->has_save($i)) {
my $save = $self->saves->[$i];
say "スロット $i: ★";
say " HP: " . $save->hp;
say " 所持金: " . $save->gold . "G";
say " 位置: " . $save->position;
say " 所持品: " . join(', ', $save->items->@*);
} else {
say "スロット $i: (空き)";
}
}
say "";
}
sub try_auto_save ($self, $player, $reason = '') {
unless ($self->auto_save) {
return;
}
# オートセーブは最後のスロットを使う
my $slot = $self->save_game_to_slot($player, $self->max_slots - 1);
if ($reason) {
say "~~~ オートセーブ: $reason ~~~";
} else {
say "~~~ オートセーブしました ~~~";
}
say "スロット $slot に保存しました";
say "";
}
};
|
新しい属性とメソッドを追加しました。
新しい属性
max_slots — 最大スロット数(デフォルト3)
1
2
3
4
| has max_slots => (
is => 'ro',
default => 3,
);
|
新しいメソッド
save_game_to_slot($player, $slot_number) — 指定したスロットに保存
1
2
3
4
5
6
7
8
9
10
| sub save_game_to_slot ($self, $player, $slot_number) {
if ($slot_number < 0 || $slot_number >= $self->max_slots) {
die "無効なスロット番号: $slot_number\n";
}
my $snapshot = $player->save_snapshot;
$self->saves->[$slot_number] = $snapshot; # 指定位置に保存
return $slot_number;
}
|
既存メソッドの改善
save_game — 空きスロットを探して保存
1
2
3
4
5
6
7
8
9
10
11
| sub save_game ($self, $player) {
# 空きスロットを探す
for my $i (0 .. $self->max_slots - 1) {
unless (defined $self->saves->[$i]) {
return $self->save_game_to_slot($player, $i);
}
}
# 空きがなければ、最後のスロットに上書き
return $self->save_game_to_slot($player, $self->max_slots - 1);
}
|
list_saves — 空きスロットも表示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| sub list_saves ($self) {
say "=== セーブデータ一覧 ===";
for my $i (0 .. $self->max_slots - 1) {
if ($self->has_save($i)) {
my $save = $self->saves->[$i];
say "スロット $i: ★";
say " HP: " . $save->hp;
say " 所持金: " . $save->gold . "G";
say " 位置: " . $save->position;
} else {
say "スロット $i: (空き)";
}
}
say "";
}
|
コード例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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
| # 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 add_item ($self, $item) {
push $self->items->@*, $item;
}
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;
say "所持品: " . join(', ', $self->items->@*);
say "";
}
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
# 配列リファレンスの浅いコピー(中身は文字列なのでこれでOK)
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->@* ] );
}
};
package GameManager {
use Moo;
has saves => (
is => 'ro',
default => sub { [] },
);
has auto_save => (
is => 'rw',
default => 1,
);
has max_slots => (
is => 'ro',
default => 3,
);
sub save_game_to_slot ($self, $player, $slot_number) {
if ($slot_number < 0 || $slot_number >= $self->max_slots) {
die "無効なスロット番号: $slot_number (0〜" . ($self->max_slots - 1) . ")\n";
}
my $snapshot = $player->save_snapshot;
$self->saves->[$slot_number] = $snapshot;
return $slot_number;
}
sub save_game ($self, $player) {
for my $i (0 .. $self->max_slots - 1) {
unless (defined $self->saves->[$i]) {
return $self->save_game_to_slot($player, $i);
}
}
return $self->save_game_to_slot($player, $self->max_slots - 1);
}
sub load_game ($self, $player, $slot_number) {
unless ($self->has_save($slot_number)) {
die "セーブデータがありません: スロット $slot_number\n";
}
my $snapshot = $self->saves->[$slot_number];
$player->restore_from_snapshot($snapshot);
}
sub has_save ($self, $slot_number) {
return defined $self->saves->[$slot_number];
}
sub list_saves ($self) {
say "=== セーブデータ一覧 ===";
for my $i (0 .. $self->max_slots - 1) {
if ($self->has_save($i)) {
my $save = $self->saves->[$i];
say "スロット $i: ★";
say " HP: " . $save->hp;
say " 所持金: " . $save->gold . "G";
say " 位置: " . $save->position;
say " 所持品: " . join(', ', $save->items->@*);
} else {
say "スロット $i: (空き)";
}
}
say "";
}
sub try_auto_save ($self, $player, $reason = '') {
unless ($self->auto_save) {
return;
}
my $slot = $self->save_game_to_slot($player, $self->max_slots - 1);
if ($reason) {
say "~~~ オートセーブ: $reason ~~~";
} else {
say "~~~ オートセーブしました ~~~";
}
say "スロット $slot に保存しました";
say "";
}
};
# ゲームシナリオ
my $player = Player->new;
my $manager = GameManager->new;
say "=== ゲーム開始 ===";
$player->show_status;
# スロット0:町で手動セーブ(安全プレイ用)
say "=== スロット 0 に手動セーブ(安全プレイ用) ===";
$manager->save_game_to_slot($player, 0);
say "スロット 0 に保存しました";
say "";
$manager->list_saves;
# ゲーム進行
$player->move_to('森');
$player->take_damage(30);
$player->earn_gold(50);
$player->show_status;
# スロット1:森で手動セーブ(冒険用)
say "=== スロット 1 に手動セーブ(冒険用) ===";
$manager->save_game_to_slot($player, 1);
say "スロット 1 に保存しました";
say "";
$manager->list_saves;
# さらにゲーム進行
$player->move_to('洞窟');
$player->take_damage(20);
$player->show_status;
# スロット2:オートセーブ
say "=== スロット 2 にオートセーブ ===";
$manager->try_auto_save($player, "エリア移動時");
$manager->list_saves;
# ボス戦
$player->move_to('ボス部屋');
$player->show_status;
say "ドラゴンと戦闘!";
$player->take_damage(100);
say "100のダメージを受けた!";
$player->show_status;
if (!$player->is_alive) {
say "=== GAME OVER ===";
say "";
$manager->list_saves;
# プレイヤーが選択
say "どのスロットから復元しますか?";
say "0: 町(安全)";
say "1: 森(冒険用)";
say "2: 洞窟(最新オートセーブ)";
say "";
# ここではスロット1を選択
my $choice = 1;
say "スロット $choice を選択しました";
say "";
$manager->load_game($player, $choice);
say "=== 復元完了 ===";
$player->show_status;
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
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
| === ゲーム開始 ===
HP: 100
所持金: 0G
位置: 町
所持品:
=== スロット 0 に手動セーブ(安全プレイ用) ===
スロット 0 に保存しました
=== セーブデータ一覧 ===
スロット 0: ★
HP: 100
所持金: 0G
位置: 町
所持品:
スロット 1: (空き)
スロット 2: (空き)
HP: 70
所持金: 50G
位置: 森
所持品:
=== スロット 1 に手動セーブ(冒険用) ===
スロット 1 に保存しました
=== セーブデータ一覧 ===
スロット 0: ★
HP: 100
所持金: 0G
位置: 町
所持品:
スロット 1: ★
HP: 70
所持金: 50G
位置: 森
所持品:
スロット 2: (空き)
HP: 50
所持金: 50G
位置: 洞窟
所持品:
=== スロット 2 にオートセーブ ===
~~~ オートセーブ: エリア移動時 ~~~
スロット 2 に保存しました
=== セーブデータ一覧 ===
スロット 0: ★
HP: 100
所持金: 0G
位置: 町
所持品:
スロット 1: ★
HP: 70
所持金: 50G
位置: 森
所持品:
スロット 2: ★
HP: 50
所持金: 50G
位置: 洞窟
所持品:
HP: 50
所持金: 50G
位置: ボス部屋
所持品:
ドラゴンと戦闘!
100のダメージを受けた!
HP: 0
所持金: 50G
位置: ボス部屋
所持品:
=== GAME OVER ===
=== セーブデータ一覧 ===
スロット 0: ★
HP: 100
所持金: 0G
位置: 町
所持品:
スロット 1: ★
HP: 70
所持金: 50G
位置: 森
所持品:
スロット 2: ★
HP: 50
所持金: 50G
位置: 洞窟
所持品:
どのスロットから復元しますか?
0: 町(安全)
1: 森(冒険用)
2: 洞窟(最新オートセーブ)
スロット 1 を選択しました
=== 復元完了 ===
HP: 70
所持金: 50G
位置: 森
所持品:
森からゲームを再開しました。
|
完璧です!3つのセーブスロットを使い分け、好きなスロットからロードできるようになりました。
セーブスロットの使い分け
今回の実装で、プレイヤーは以下のようにスロットを使い分けられます。
graph TB
A["スロット 0
安全プレイ用"] --> A1["重要なポイント前に
手動でセーブ"]
B["スロット 1
冒険用"] --> B1["新エリア挑戦前に
手動でセーブ"]
C["スロット 2
最新オートセーブ"] --> C1["エリア移動時に
自動でセーブ"]
style A fill:#9f9
style B fill:#99f
style C fill:#ff9
この使い分けにより、プレイヤーは自分のプレイスタイルに合わせてセーブ・ロードを管理できます。
スロット管理の設計ポイント
1. スロット数の制限
max_slotsを導入することで、無限にセーブデータが増えることを防ぎます。
1
2
3
4
| has max_slots => (
is => 'ro',
default => 3,
);
|
2. 空きスロットの表示
list_savesで空きスロットも表示することで、プレイヤーに選択肢を明示します。
1
2
3
4
5
| if ($self->has_save($i)) {
# 保存済み
} else {
say "スロット $i: (空き)";
}
|
3. オートセーブ専用スロット
オートセーブは常に最後のスロット(スロット2)を使うことで、手動セーブと分離します。
1
2
3
4
5
| sub try_auto_save ($self, $player, $reason = '') {
# ...
my $slot = $self->save_game_to_slot($player, $self->max_slots - 1);
# ...
}
|
4. エラーハンドリング
無効なスロット番号を指定された場合は、明確なエラーメッセージを表示します。
1
2
3
| if ($slot_number < 0 || $slot_number >= $self->max_slots) {
die "無効なスロット番号: $slot_number (0〜" . ($self->max_slots - 1) . ")\n";
}
|
まだできていないこと
今回の実装で、複数セーブスロット機能が完成しました。しかし、まだいくつかの改善の余地があります。
- 対話的なインターフェースがない — ユーザー入力によるスロット選択
- セーブデータの永続化がない — プログラム終了後も保存できない
- セーブデータのメタ情報がない — セーブ日時やプレイ時間など
次回は、これまでに作成したすべての機能を統合し、対話的なCLIでゲームをプレイできるようにします。
今回作成した完成コード
以下が今回作成した完成コードです。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
187
188
189
190
191
| #!/usr/bin/env perl
# 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 add_item ($self, $item) {
push $self->items->@*, $item;
}
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;
say "所持品: " . join(', ', $self->items->@*);
say "";
}
sub save_snapshot ($self) {
return PlayerSnapshot->new(
hp => $self->hp,
gold => $self->gold,
position => $self->position,
# 配列リファレンスの浅いコピー(中身は文字列なのでこれでOK)
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->@* ] );
}
};
package GameManager {
use Moo;
has saves => (
is => 'ro',
default => sub { [] },
);
has auto_save => (
is => 'rw',
default => 1,
);
has max_slots => (
is => 'ro',
default => 3,
);
sub save_game_to_slot ($self, $player, $slot_number) {
if ($slot_number < 0 || $slot_number >= $self->max_slots) {
die "無効なスロット番号: $slot_number (0〜" . ($self->max_slots - 1) . ")\n";
}
my $snapshot = $player->save_snapshot;
$self->saves->[$slot_number] = $snapshot;
return $slot_number;
}
sub save_game ($self, $player) {
for my $i (0 .. $self->max_slots - 1) {
unless (defined $self->saves->[$i]) {
return $self->save_game_to_slot($player, $i);
}
}
return $self->save_game_to_slot($player, $self->max_slots - 1);
}
sub load_game ($self, $player, $slot_number) {
unless ($self->has_save($slot_number)) {
die "セーブデータがありません: スロット $slot_number\n";
}
my $snapshot = $self->saves->[$slot_number];
$player->restore_from_snapshot($snapshot);
}
sub has_save ($self, $slot_number) {
return defined $self->saves->[$slot_number];
}
sub list_saves ($self) {
say "=== セーブデータ一覧 ===";
for my $i (0 .. $self->max_slots - 1) {
if ($self->has_save($i)) {
my $save = $self->saves->[$i];
say "スロット $i: ★";
say " HP: " . $save->hp;
say " 所持金: " . $save->gold . "G";
say " 位置: " . $save->position;
say " 所持品: " . join(', ', $save->items->@*);
} else {
say "スロット $i: (空き)";
}
}
say "";
}
sub try_auto_save ($self, $player, $reason = '') {
unless ($self->auto_save) {
return;
}
my $slot = $self->save_game_to_slot($player, $self->max_slots - 1);
if ($reason) {
say "~~~ オートセーブ: $reason ~~~";
} else {
say "~~~ オートセーブしました ~~~";
}
say "スロット $slot に保存しました";
say "";
}
};
# ゲームシナリオ(省略。上記のコード例2と同じ)
|
まとめ
今回は、複数のセーブスロットから選んでロードできる機能を追加しました。
作成したもの:
max_slots属性で、セーブスロット数を制限したsave_game_to_slotメソッドで、指定したスロットにセーブできるようにしたlist_savesメソッドで、空きスロットも表示するよう改善した- オートセーブ専用スロットを分離した
学んだこと:
- セーブスロットの使い分け(安全プレイ用、冒険用、オートセーブ用)
- スロット数の制限によるメモリ管理
- 空きスロットの表示による選択肢の明示
- エラーハンドリングの重要性
設計の利点:
- プレイヤーが自分のプレイスタイルに合わせて管理できる
- 手動セーブとオートセーブの分離
- 無限にセーブデータが増えることを防止
実際のゲームのようなセーブスロット機能が完成しました!次回は、全機能を統合した完成版RPGを作ります。
次回予告
今回、複数セーブスロット機能が完成しました。
次回は、これまでに作成したすべての機能を統合し、セーブ機能付きRPGを完成させます。対話的なCLIでゲームをプレイできるようにし、実際のゲームプレイデモを行います。
すべての機能が組み合わさった、完成したゲームスクリプトをお見せします。
第9回のテーマ: 完成!セーブ機能付きRPG
お楽しみに。