Featured image of post 第5回-セーブデータを管理しよう(履歴機能) - Mooを使ってゲームのセーブ機能を作ってみよう

第5回-セーブデータを管理しよう(履歴機能) - Mooを使ってゲームのセーブ機能を作ってみよう

複数のセーブポイントを管理するGameManagerクラスを作成。セーブスロット機能でsave_game・load_gameメソッドを実装します。

@nqounetです。

前回の振り返り

前回は、保存したスナップショットから、プレイヤーの状態を復元するrestore_from_snapshotメソッドを実装しました。これにより、セーブ→ダメージ→ロードの基本的な流れが完成しました。

前回までに作成したもの

  • PlayerSnapshotクラス — プレイヤーの状態を保存する不変オブジェクト
  • save_snapshotメソッド — プレイヤーの状態からスナップショットを作成
  • restore_from_snapshotメソッド — スナップショットから状態を復元

まだできていないこと

  • 複数のセーブスロットがない — 1つのスナップショットしか保存できない
  • セーブスロットの管理機能がない — どのセーブデータをロードするか選べない
  • 自動セーブ機能がない — 手動でsave_snapshotを呼ぶ必要がある

今回は、複数のセーブポイントを管理する仕組みを作ります。

今回のゴール

今回のゴールは、複数のセーブデータを管理できるようにすることです。

  • GameManagerクラスを作成する
  • セーブスロット(複数のスナップショット)を管理する
  • save_gameメソッドとload_gameメソッドを実装する
  • セーブデータの一覧表示機能を追加する

これで、実際のゲームのような「セーブスロット1、2、3…」という機能が実現できます。

なぜGameManagerクラスが必要なのか

現状のコードでは、スナップショットを変数に保存しています。

1
my $snapshot = $player->save_snapshot;

しかし、これには問題があります。

問題1:複数のセーブデータを管理できない

1
2
3
4
5
6
7
my $snapshot1 = $player->save_snapshot;  # 町でセーブ
# ...ゲーム進行...
my $snapshot2 = $player->save_snapshot;  # 森でセーブ
# ...ゲーム進行...
my $snapshot3 = $player->save_snapshot;  # 洞窟でセーブ

# どのスナップショットをロードするか?

変数が増えるほど管理が大変になります。

問題2:セーブデータの一覧が見られない

「いつ、どこでセーブしたか」がわかりません。

問題3:セーブデータの保存・読み込みロジックが分散

セーブデータの管理ロジックが、ゲームループのコードに散らばってしまいます。

これらの問題を解決するために、セーブデータを管理する専門のクラスを作ります。それがGameManagerです。

GameManagerクラスの設計

GameManagerクラスは、複数のスナップショットを管理する「セーブデータマネージャー」です。

	classDiagram
    class GameManager {
        -saves: ArrayRef
        +save_game(player)
        +load_game(player, slot_number)
        +list_saves()
        +has_save(slot_number)
    }
    class Player {
        +save_snapshot()
        +restore_from_snapshot(snapshot)
    }
    class PlayerSnapshot {
        -hp: Int
        -gold: Int
        -position: Str
        -items: ArrayRef
    }
    
    GameManager --> PlayerSnapshot : 管理する
    GameManager --> Player : 保存・復元
    Player --> PlayerSnapshot : 作成・復元

GameManagerは、複数のPlayerSnapshotを配列で管理します。そして、Playerの状態を保存したり復元したりする窓口になります。

コード例1:GameManagerクラスの実装

それでは、GameManagerクラスを作成しましょう。

 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
# Perl v5.36 以降
# 外部依存: Moo

package GameManager {
    use Moo;
    use v5.36;

    has saves => (
        is      => 'ro',
        default => sub { [] },
    );

    sub save_game ($self, $player) {
        my $snapshot = $player->save_snapshot;
        push $self->saves->@*, $snapshot;
        return scalar $self->saves->@* - 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) {
        my @saves = $self->saves->@*;
        
        if (@saves == 0) {
            say "セーブデータがありません";
            return;
        }
        
        say "=== セーブデータ一覧 ===";
        for my $i (0 .. $#saves) {
            my $save = $saves[$i];
            say "スロット $i:";
            say "  HP: " . $save->hp;
            say "  所持金: " . $save->gold . "G";
            say "  位置: " . $save->position;
        }
        say "";
    }
};

このクラスは、以下の機能を持っています。

属性

saves — セーブデータを保存する配列リファレンス

1
2
3
4
has saves => (
    is      => 'ro',
    default => sub { [] },
);

is => 'ro'で読み取り専用にしていますが、配列の中身は変更できます。これは、配列リファレンス自体は変わらないが、配列の要素は追加・削除できるという意味です。

default => sub { [] }で、新しい空の配列リファレンスを作成します。default => []ではなくsub { [] }を使う理由は、各オブジェクトが独自の配列を持つようにするためです。

メソッド

save_game($player) — プレイヤーの状態を保存

1
2
3
4
5
sub save_game ($self, $player) {
    my $snapshot = $player->save_snapshot;
    push @{$self->saves}, $snapshot;
    return scalar @{$self->saves} - 1;  # スロット番号を返す
}

プレイヤーからスナップショットを作成し、配列に追加します。そして、保存したスロット番号を返します。

load_game($player, $slot_number) — セーブデータを読み込む

1
2
3
4
5
6
7
8
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);
}

指定されたスロット番号のスナップショットを取得し、プレイヤーに復元します。存在しないスロットを指定された場合は、エラーを投げます。

has_save($slot_number) — セーブデータが存在するか確認

1
2
3
sub has_save ($self, $slot_number) {
    return defined $self->saves->[$slot_number];
}

指定されたスロットにセーブデータがあるかチェックします。

list_saves() — セーブデータの一覧を表示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
sub list_saves ($self) {
    my @saves = @{$self->saves};
    
    if (@saves == 0) {
        say "セーブデータがありません";
        return;
    }
    
    say "=== セーブデータ一覧 ===";
    for my $i (0 .. $#saves) {
        my $save = $saves[$i];
        say "スロット $i:";
        say "  HP: " . $save->hp;
        say "  所持金: " . $save->gold . "G";
        say "  位置: " . $save->position;
    }
    say "";
}

保存されているすべてのセーブデータを一覧表示します。

GameManagerの役割

GameManagerクラスは、デザインパターンの用語でCaretaker(管理者)と呼ばれる役割を担っています。

	graph LR
    A["Player
    (Originator)"] -->|"save_snapshot()"| B["PlayerSnapshot
    (Memento)"]
    C["GameManager
    (Caretaker)"] -->|"保存・管理"| B
    C -->|"save_game / load_game"| A
    
    style A fill:#9ff
    style B fill:#9f9
    style C fill:#ff9

3つの役割を整理しましょう。

役割クラス責任
Originator(作成者)Player自分の状態を知っている。スナップショットを作成・復元する
Memento(記念品)PlayerSnapshot状態を保存する。不変(読み取り専用)
Caretaker(管理者)GameManagerスナップショットを管理する。いつ保存・復元するかを決める

それぞれの責任が明確に分かれています。これが良い設計です。

コード例2:save_game、load_gameメソッドの動作確認

それでは、GameManagerを使って、複数のセーブスロットを管理してみましょう。

  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
# 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 get_item ($self, $item) {
        push $self->items->@*, $item;
    }

    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;
        if ($self->items->@*) {
            say "持ち物: " . join(', ', $self->items->@*);
        } else {
            say "持ち物: なし";
        }
        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->@* ]); # 重要:復元時も新しい配列を作成
    }
};

package GameManager {
    use Moo;

    has saves => (
        is      => 'ro',
        default => sub { [] },
    );

    sub save_game ($self, $player) {
        my $snapshot = $player->save_snapshot;
        push $self->saves->@*, $snapshot;
        return scalar $self->saves->@* - 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) {
        my @saves = $self->saves->@*;
        
        if (@saves == 0) {
            say "セーブデータがありません";
            return;
        }
        
        say "=== セーブデータ一覧 ===";
        for my $i (0 .. $#saves) {
            my $save = $saves[$i];
            say "スロット $i:";
            say "  HP: " . $save->hp;
            say "  所持金: " . $save->gold . "G";
            say "  位置: " . $save->position;
        }
        say "";
    }
};

# ゲームループのデモ
my $player  = Player->new;
my $manager = GameManager->new;

say "=== ゲーム開始 ===";
$player->show_status;

# セーブスロット0:町でセーブ
say "=== セーブスロット 0 ===";
my $slot0 = $manager->save_game($player);
say "スロット $slot0 に保存しました";
say "";

say "森へ移動...";
$player->move_to('森');
$player->show_status;

say "スライムと戦闘!";
$player->take_damage(30);
say "30のダメージを受けた!";
$player->show_status;

say "スライムを倒した!";
$player->earn_gold(50);
say "50Gを手に入れた!";
$player->get_item('薬草');
say "薬草を手に入れた!";
$player->show_status;

# セーブスロット1:森でセーブ
say "=== セーブスロット 1 ===";
my $slot1 = $manager->save_game($player);
say "スロット $slot1 に保存しました";
say "";

say "洞窟へ移動...";
$player->move_to('洞窟');
$player->show_status;

# セーブスロット2:洞窟でセーブ
say "=== セーブスロット 2 ===";
my $slot2 = $manager->save_game($player);
say "スロット $slot2 に保存しました";
say "";

# セーブデータ一覧を表示
$manager->list_saves;

# ドラゴンと戦闘
say "ドラゴンと戦闘!";
$player->take_damage(80);
say "80のダメージを受けた!";
$player->show_status;

if (!$player->is_alive) {
    say "=== GAME OVER ===";
    say "";
    
    # スロット1(森)からロード
    say "スロット 1 から復元します...";
    $manager->load_game($player, 1);
    
    say "=== 復元完了 ===";
    $player->show_status;
    
    say "スロット 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
=== ゲーム開始 ===
HP: 100
所持金: 0G
位置: 町
持ち物: なし

=== セーブスロット 0 ===
スロット 0 に保存しました

森へ移動...
HP: 100
所持金: 0G
位置: 森
持ち物: なし

スライムと戦闘!
30のダメージを受けた!
HP: 70
所持金: 0G
位置: 森
持ち物: なし

スライムを倒した!
50Gを手に入れた!
薬草を手に入れた!
HP: 70
所持金: 50G
位置: 森
持ち物: 薬草

=== セーブスロット 1 ===
スロット 1 に保存しました

洞窟へ移動...
HP: 70
所持金: 50G
位置: 洞窟
持ち物: 薬草

=== セーブスロット 2 ===
スロット 2 に保存しました

=== セーブデータ一覧 ===
スロット 0:
  HP: 100
  所持金: 0G
  位置: 町
スロット 1:
  HP: 70
  所持金: 50G
  位置: 森
スロット 2:
  HP: 70
  所持金: 50G
  位置: 洞窟

ドラゴンと戦闘!
80のダメージを受けた!
HP: 0
所持金: 50G
位置: 洞窟
持ち物: 薬草

=== GAME OVER ===

スロット 1 から復元します...
=== 復元完了 ===
HP: 70
所持金: 50G
位置: 森
持ち物: 薬草

スロット 1(森)からゲームを再開しました。

完璧です!複数のセーブスロットが管理でき、好きなスロットからロードできるようになりました。

設計のポイント

今回の設計には、いくつかの重要なポイントがあります。

1. 責任の分離

PlayerPlayerSnapshotGameManagerの3つのクラスが、それぞれ明確な責任を持っています。

	graph TB
    subgraph "Player の責任"
        A1["自分の状態を管理"]
        A2["スナップショット作成"]
        A3["スナップショットから復元"]
    end
    
    subgraph "PlayerSnapshot の責任"
        B1["状態を保存"]
        B2["不変性を保証"]
    end
    
    subgraph "GameManager の責任"
        C1["スナップショットを管理"]
        C2["保存・復元のタイミング制御"]
        C3["セーブデータ一覧表示"]
    end
    
    style A1 fill:#9ff
    style B1 fill:#9f9
    style C1 fill:#ff9

この分離により、それぞれのクラスが独立して変更できるようになります。

2. カプセル化の維持

GameManagerを使うことで、セーブデータの管理ロジックが隠蔽されます。

1
2
3
# 外部からは、save_game と load_game を呼ぶだけ
$manager->save_game($player);
$manager->load_game($player, 1);

配列の操作やスロット番号の管理は、GameManagerの中に隠されています。

3. 将来の拡張に強い

例えば、セーブデータにタイムスタンプを追加したい場合、GameManagerクラスだけを修正すれば済みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 将来の拡張例
sub save_game ($self, $player) {
    my $snapshot = $player->save_snapshot;
    my $save_data = {
        snapshot  => $snapshot,
        timestamp => time,  # 追加
    };
    push @{$self->saves}, $save_data;
    return scalar @{$self->saves} - 1;
}

外部のコードは変更不要です。

default => sub { [] } の重要性

GameManagerクラスのsaves属性で、default => sub { [] }を使いました。

1
2
3
4
has saves => (
    is      => 'ro',
    default => sub { [] },
);

なぜdefault => []ではなくsub { [] }を使うのでしょうか。

間違った例:default => []

1
2
3
4
has saves => (
    is      => 'ro',
    default => [],  # これはダメ
);

この書き方だと、すべてのGameManagerオブジェクトが同じ配列を共有してしまいます。

1
2
3
4
5
6
7
my $manager1 = GameManager->new;
my $manager2 = GameManager->new;

$manager1->save_game($player1);

# $manager2 の saves にも影響してしまう!
say scalar @{$manager2->saves};  # 1

これは、Perlのリファレンスの仕組みによるものです。default => []は、プログラム起動時に1度だけ配列リファレンスを作成し、それをすべてのオブジェクトで共有します。

正しい例:default => sub { [] }

1
2
3
4
has saves => (
    is      => 'ro',
    default => sub { [] },  # 正しい
);

sub { [] }を使うと、オブジェクトが作成されるたびに、新しい配列リファレンスが作成されます。

1
2
3
4
5
6
7
my $manager1 = GameManager->new;
my $manager2 = GameManager->new;

$manager1->save_game($player1);

# $manager2 は影響を受けない
say scalar @{$manager2->saves};  # 0

これが、defaultにリファレンス(配列やハッシュ)を指定する際の正しい書き方です。

参照コピーとシャローコピー

itemsの保存で、以下のようなコードを書きました。

1
items => [ $self->items->@* ], # 重要:参照コピーではなく、新しい配列を作成(シャローコピー)

なぜ、単純に以下のように書いてはいけないのでしょうか?

1
items => $self->items, # 参照コピー(間違い)

参照コピーの問題点

$self->itemsは配列リファレンスです。これをそのまま保存すると、「元の配列そのもの」を保存することになります。

1
2
3
4
5
6
7
my $snapshot = $player->save_snapshot; # この時点では items は ['薬草']

# その後、プレイヤーがアイテムを追加
$player->get_item('剣');

# 参照コピーの場合、過去のスナップショットの中身も変わってしまう!
# $snapshot->items も ['薬草', '剣'] になっている

これでは、過去の状態を保存したことになりません。

シャローコピー(浅いコピー)

そこで、[ $self->items->@* ] とすることで、「新しい配列を作って、中身をコピー」しています。これをシャローコピーと呼びます。

これにより、元の配列が変更されても、スナップショット側の配列は影響を受けません。

なぜシャローコピーで十分なのか?

「ディープコピー(深いコピー)は必要ないの?」と思うかもしれません。

今回の場合、itemsの中身は「文字列(スカラ)」だけです。文字列は不変(変更不可)なので、配列の側さえ新しくしておけば、中身までコピーする必要はありません。

もし、itemsの中身が「オブジェクト」や「別のリファレンス」だった場合は、ディープコピーが必要になることもありますが、今回のゲームではシャローコピーで十分です。

まだできていないこと

今回の実装で、複数のセーブスロット管理機能が完成しました。しかし、まだいくつかの課題があります。

  1. 自動セーブ機能がない — 特定のイベントで自動的にセーブする機能
  2. セーブスロット数の制限がない — 無限にセーブできてしまう
  3. セーブデータの上書き保護がない — 既存のスロットを上書きする際の確認がない

次回は、特定のイベント(ボス戦前、エリア移動時)で自動的にセーブする「オートセーブ機能」を追加します。

今回作成した完成コード

以下が今回作成した完成コードです。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
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
#!/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 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 get_item ($self, $item) {
        push $self->items->@*, $item;
    }

    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;
        if ($self->items->@*) {
            say "持ち物: " . join(', ', $self->items->@*);
        } else {
            say "持ち物: なし";
        }
        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->@* ]); # 重要:復元時も新しい配列を作成
    }
};

package GameManager {
    use Moo;

    has saves => (
        is      => 'ro',
        default => sub { [] },
    );

    sub save_game ($self, $player) {
        my $snapshot = $player->save_snapshot;
        push $self->saves->@*, $snapshot;
        return scalar $self->saves->@* - 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) {
        my @saves = $self->saves->@*;
        
        if (@saves == 0) {
            say "セーブデータがありません";
            return;
        }
        
        say "=== セーブデータ一覧 ===";
        for my $i (0 .. $#saves) {
            my $save = $saves[$i];
            say "スロット $i:";
            say "  HP: " . $save->hp;
            say "  所持金: " . $save->gold . "G";
            say "  位置: " . $save->position;
        }
        say "";
    }
};

# ゲームループのデモ
my $player  = Player->new;
my $manager = GameManager->new;

say "=== ゲーム開始 ===";
$player->show_status;

# セーブスロット0:町でセーブ
say "=== セーブスロット 0 ===";
my $slot0 = $manager->save_game($player);
say "スロット $slot0 に保存しました";
say "";

say "森へ移動...";
$player->move_to('森');
$player->show_status;

say "スライムと戦闘!";
$player->take_damage(30);
say "30のダメージを受けた!";
$player->show_status;

say "スライムを倒した!";
$player->earn_gold(50);
say "50Gを手に入れた!";
$player->get_item('薬草');
say "薬草を手に入れた!";
$player->show_status;

# セーブスロット1:森でセーブ
say "=== セーブスロット 1 ===";
my $slot1 = $manager->save_game($player);
say "スロット $slot1 に保存しました";
say "";

say "洞窟へ移動...";
$player->move_to('洞窟');
$player->show_status;

# セーブスロット2:洞窟でセーブ
say "=== セーブスロット 2 ===";
my $slot2 = $manager->save_game($player);
say "スロット $slot2 に保存しました";
say "";

# セーブデータ一覧を表示
$manager->list_saves;

# ドラゴンと戦闘
say "ドラゴンと戦闘!";
$player->take_damage(80);
say "80のダメージを受けた!";
$player->show_status;

if (!$player->is_alive) {
    say "=== GAME OVER ===";
    say "";
    
    # スロット1(森)からロード
    say "スロット 1 から復元します...";
    $manager->load_game($player, 1);
    
    say "=== 復元完了 ===";
    $player->show_status;
    
    say "スロット 1(森)からゲームを再開しました。";
}

まとめ

今回は、複数のセーブポイントを管理するGameManagerクラスを作成しました。

作成したもの:

  • GameManagerクラスで、複数のスナップショットを管理できるようにした
  • save_gameメソッドで、プレイヤーの状態を配列に保存した
  • load_gameメソッドで、指定したスロットから状態を復元した
  • list_savesメソッドで、セーブデータの一覧を表示した

学んだこと:

  • Caretaker(管理者)の役割
  • 責任の分離(Player、PlayerSnapshot、GameManagerの3つの役割)
  • default => sub { [] }の重要性(リファレンスのデフォルト値)
  • カプセル化を維持した設計

設計の利点:

  • 複数のセーブスロットを簡単に管理できる
  • セーブデータの管理ロジックがGameManagerに集約されている
  • 将来の拡張(タイムスタンプ追加など)に強い

実際のゲームのような「セーブスロット1、2、3…」という機能が実現できました!次回は、オートセーブ機能を追加します。

次回予告

今回、複数のセーブスロット管理機能が完成しました。

しかし、現状では手動でsave_gameを呼ぶ必要があります。実際のゲームでは、ボス戦前やエリア移動時に自動的にセーブされることがよくあります。

次回は、特定のイベント(ボス戦前、エリア移動時)で自動的にセーブする「オートセーブ機能」を追加します。auto_saveフラグと自動セーブロジックを実装し、より本格的なゲームのセーブ機能に近づけます。

第6回のテーマ: オートセーブを追加しよう

お楽しみに。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。