実践ガイド

Godotで放置ゲームを作る⑦:自動化モジュールとJSONセーブ/ロード

※本記事にはアフィリエイト広告(Udemy)を含みます。

前回のおさらい

前回⑥では、中央制御システムによる乗算バフを実装し、すべての生産量を底上げできるようになりました。今回は、クリック操作そのものを不要にする「自動化モジュール」と、ゲームの進行状況を保存するセーブ/ロード機能を実装します。

今回のゴール

STATION OPSの設備は、これまですべて「基数を増やせるだけ増やせる」設計でした。自動化モジュールだけは例外で、1回購入したらそれで終わりの設備にします。購入すると、以後は手動クリックの代わりに固定量の電力が自動で加算されるようになります。

あわせて、ここまで育てた設備を保存・復元するセーブ/ロード機能を実装します。ブラウザの保存が許可されない場合や保存データが消去された場合は復元できません。保存形式にはJSONを採用します。

自動化モジュールを追加する

1回限りの購入にする設計

発電パネルなどはcount(所持数)を増やしていく設計でしたが、自動化モジュールは「買った/買っていない」の2状態だけを持てば十分です。そのため、真偽値(bool)の変数で管理します。

var automation_module_purchased: bool = false
var automation_module_cost: float = 50.0

const AUTOMATION_MODULE_FIXED_BONUS: float = 5.0


func can_buy_automation_module() -> bool:
    return central_control_count >= 1 and not automation_module_purchased


func buy_automation_module() -> bool:
    if not can_buy_automation_module():
        return false
    if parts < automation_module_cost:
        return false
    parts -= automation_module_cost
    automation_module_purchased = true
    resources_changed.emit()
    return true

can_buy_automation_module()の中でnot automation_module_purchasedを条件に加えることで、一度購入した後は二度と購入できないようにしています。

Tick処理に固定ボーナスを追加する

func _on_production_tick() -> void:
    var multiplier: float = get_production_multiplier()

    # (中略:発電パネル・酸素生成装置・部品工場の処理は⑤⑥と同じ)

    if automation_module_purchased:
        power += AUTOMATION_MODULE_FIXED_BONUS * multiplier

    resources_changed.emit()
    save_game()

固定ボーナスにも中央制御システムのmultiplierを掛けているのは、自動化モジュールも「生産」の一種として扱い、他の設備と同じ恩恵を受けられるようにするためです。

UIからクリックボタンを消す

購入後はクリックボタンを非表示にする理由

自動化モジュールを購入したということは、もう手動でクリックする必要がなくなったということです。不要になった手動発電ボタンを画面から非表示にします。

func _update_display() -> void:
    click_button.visible = not GameManager.automation_module_purchased
    # (以下、これまでの表示更新処理)

JSON形式でセーブ/ロードを実装する

なぜJSON形式を選んだか

Godotには設定ファイル向けのConfigFileという仕組みもありますが、今回はJSON形式を選びました。JSONはGodot以外のツールとの連携や、将来的にセーブデータの内容を確認・編集する場面でも扱いやすい、汎用的なデータ形式だからです。

save_game()関数を書く

const SAVE_PATH: String = "user://savegame.json"


func save_game() -> void:
    var data: Dictionary = {
        "power": power,
        "oxygen": oxygen,
        "parts": parts,
        "power_panel_count": power_panel_count,
        "power_panel_cost": power_panel_cost,
        "oxygen_generator_count": oxygen_generator_count,
        "oxygen_generator_cost": oxygen_generator_cost,
        "parts_factory_count": parts_factory_count,
        "parts_factory_cost": parts_factory_cost,
        "central_control_count": central_control_count,
        "central_control_cost": central_control_cost,
        "automation_module_purchased": automation_module_purchased,
        "automation_module_cost": automation_module_cost,
    }
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_warning("セーブファイルを開けませんでした")
        return
    file.store_string(JSON.stringify(data))
    file.close()

user://は、Godotがユーザーごとのデータ保存用に用意している特別なパスです。OSごとに実際の保存場所は異なりますが、コード側はuser://savegame.jsonと書くだけで、環境を意識せずに扱えます。

JSON.stringify(data)で、辞書(Dictionary)のデータをJSON形式の文字列に変換してから、ファイルに書き込んでいます。

load_game()関数を書く

func load_game() -> void:
    if not FileAccess.file_exists(SAVE_PATH):
        return
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_warning("セーブファイルを読み込めませんでした")
        return
    var text: String = file.get_as_text()
    file.close()

    var parsed = JSON.parse_string(text)
    if parsed == null or typeof(parsed) != TYPE_DICTIONARY:
        push_warning("セーブデータの形式が不正です")
        return

    power = parsed.get("power", power)
    oxygen = parsed.get("oxygen", oxygen)
    parts = parsed.get("parts", parts)
    power_panel_count = parsed.get("power_panel_count", power_panel_count)
    power_panel_cost = parsed.get("power_panel_cost", power_panel_cost)
    oxygen_generator_count = parsed.get("oxygen_generator_count", oxygen_generator_count)
    oxygen_generator_cost = parsed.get("oxygen_generator_cost", oxygen_generator_cost)
    parts_factory_count = parsed.get("parts_factory_count", parts_factory_count)
    parts_factory_cost = parsed.get("parts_factory_cost", parts_factory_cost)
    central_control_count = parsed.get("central_control_count", central_control_count)
    central_control_cost = parsed.get("central_control_cost", central_control_cost)
    automation_module_purchased = parsed.get("automation_module_purchased", automation_module_purchased)
    automation_module_cost = parsed.get("automation_module_cost", automation_module_cost)

FileAccess.file_exists(SAVE_PATH)で、そもそもセーブファイルが存在するかどうかを先に確認しています。初回起動時にはまだファイルが存在しないため、この時点で処理を終え、変数は初期値のまま続行します。

parsed.get("power", power)という書き方は、「セーブデータにpowerのキーがあればその値を、なければ現在の値(初期値)のまま使う」という意味です。この書き方にしておくことで、項目が欠けた古いデータには対応しやすくなります。ただし値の型や範囲が不正な場合までは防げないため、実運用では各項目を検証してください。

どのタイミングで保存するか

上のTick例では毎秒save_game()を呼びます。購入直後にも保存したい場合は、各購入関数の成功時に呼び出しを追加してください。頻繁な書き込みの影響や、途中終了時のデータ破損への対策は環境ごとに検証が必要です。

起動時にロードを反映する

func _ready() -> void:
    load_game()
    _production_timer = Timer.new()
    _production_timer.wait_time = 1.0
    _production_timer.autostart = true
    _production_timer.timeout.connect(_on_production_tick)
    add_child(_production_timer)

load_game()は、Timerを作る前、_ready()の一番最初に呼び出します。保存した状態を読み込んでから生産処理を開始することで、初期化の順序が分かりやすくなります。この例には終了中の経過時間を計算する処理はありません。ブラウザの非アクティブタブでは処理が停止する場合もあります。

動作確認

中央制御システムを1基持ち、部品を50以上貯めた状態で自動化モジュールを購入し、クリックボタンが消えることを確認してください。少なくとも次の保存Tickが完了するまで待ってからページを再読み込みし、電力・酸素・部品や各設備の所持数がリセットされずに引き継がれていれば、セーブ/ロードは成功です。

次回予告

次回⑧では、ECHOのセリフに演出を加え、記事シリーズの仕上げと公開作業を行います。

Godot学習におすすめのオンライン講座

セーブ/ロードの実装は地味に感じるかもしれませんが、多くのゲームで必須の機能です。体系的な教材でファイル操作の基礎から押さえておくと安心です。

Godotのコースを見る

参考リンク


← 記事一覧・検索