
今回はセーブ&ロード機能を実装します。前回はメニュー画面の枠組みと設定の実装を行いました。今回のセーブ機能はメニュー画面からの続きです。ロード機能はメニューの設定から行えるものとします。
メニューが作成されていることを前提としております。メニューは前回記事で作成しました。詳しくはそちらを参考にしたうえで、この記事をご確認いただけたらと思います。
今まで作成してきたものは以下の通りです。詳しくは過去記事をご確認ください。
- ダンジョンの壁と床の設置
- プレイヤーの現在座標とその表記
- イベントメッセージの表示
- 敵とのランダムエンカウント
- ダンジョンの扉と階段の設置
- ダンジョンのB1から街に戻る処理
- ダンジョンのB1からB2に降りる処理
- ダンジョン画像の置き換え
- 座標データ管理 (階段)
- 敵データ作成 (JSON)
- 敵の複数体ランダム出現処理
- 敵の名前切り替え処理 (英語 / 日本語)
- 街の各場所のメニュー表示
- 街のBGM処理
- オートマッピング
- キャラクターの作成
- 冒険者ギルドの実装
- メニュー画面 (枠組み)
セーブ機能

セーブ機能はメニュー画面から行います。逆に言うとメニュー画面以外ではセーブできません。セーブスロットは全部で9つあり、3つずつ表示されます。
ノード構成

- MenuWindow (Control)
- MainMenuLayout (PanelContainer)
- MainHBox (HBoxContainer)
- LMainMenuLayout (VBoxContainer)
- DummySpaceL (Control)
- MessageContainer (PanelContainer)
- MenuMessageLabel (Label)
- PartyLayout (VBoxContainer)
- (省略)
- ConfigLayout (VBoxContainer)
- (省略)
- SaveLayout (VBoxContainer) ※ 新規
- SaveSlotContainer (VBoxContainer) ※ 新規
- DummySpaceSaveSlot (Control) ※ 新規
- SaveLabel (Label) ※ 新規
- SlotRow1 (HBoxContainer) ※ 新規
- SlotNoLabel (Label) ※ 新規
- SaveSlot1 (Button) ※ 新規
- SlotHBox (HBoxContainer) ※ 新規
- DummySpaceSlot (Control) ※ 新規
- SlotLeft (HBoxContainer) ※ 新規
- SlotAppearance (TextureRect) ※ 新規
- SlotRight (VBoxContainer) ※ 新規
- SlotPlayTimeLabel (Label) ※ 新規
- SlotCharaInfoLabel (Label) ※ 新規
- SlotLvLabel (Label) ※ 新規
- SlotClassLabel (Label) ※ 新規
- SlotHBox (HBoxContainer) ※ 新規
- SlotRow2 (SaveSlot1 と同じ) ※ 新規
- SlotRow3 (SaveSlot1 と同じ) ※ 新規
- SaveBottomHBox (HBoxContainer) ※ 新規
- NextSlotButton (Button) ※ 新規
- SaveBackButton (Button) ※ 新規
- SaveSlotContainer (VBoxContainer) ※ 新規
- RMainMenuLayout (VBoxContainer)
- (省略)
- SaveConfirmDialog (ConfirmationDialog) ※ 新規
- SaveAlertDialog (AcceptDialog) ※ 新規
- LMainMenuLayout (VBoxContainer)
- MainHBox (HBoxContainer)
- MainMenuLayout (PanelContainer)
| SaveLayout (VBoxContainer) | セーブ画面用レイアウト。非表示 |
| SaveSlotContainer (VBoxContainer) | セーブスロット画面中央部 |
| DummySpaceSaveSlot (Control) | スペース調整用。Custom Minimum Size で調整 |
| SaveLabel (Label) | セーブ |
| SlotRow1 (HBoxContainer) | セーブスロット1行目 |
| SlotNoLabel (Label) | セーブスロット番号。No.1 ~ 9 |
| SaveSlot1 (Button) | セーブスロット1。ボタンで実装 |
| SlotHBox (HBoxContainer) | セーブスロット内部を左右に分ける |
| DummySpaceSaveSlot (Control) | スペース調整用。Custom Minimum Size で調整 |
| SlotLeft (HBoxContainer) | セーブスロット内部左側 |
| SlotAppearance (TextureRect) | 前衛1 (PartyMember1) の見た目画像。前衛1がいない場合は前衛2, 3, 後衛1, 2, 3 と順番に探し、最初にヒットしたパーティメンバーの見た目画像になる。 |
| SlotRight (VBoxContainer) | セーブスロット内部右側 |
| SlotPlayTimeLabel (Label) | プレイ時間 |
| SlotNameLabel (Label) | 前衛1 (PartyMember1) の名前。前衛1がいない場合は前衛2, 3, 後衛1, 2, 3 と順番に探し、最初にヒットしたパーティメンバーの名前になる。 |
| SlotLvLabel (Label) | 前衛1 (PartyMember1) のレベル。前衛1がいない場合は前衛2, 3, 後衛1, 2, 3 と順番に探し、最初にヒットしたパーティメンバーのレベルになる。 |
| SlotClassLabel (Label) | 前衛1 (PartyMember1) の職業。前衛1がいない場合は前衛2, 3, 後衛1, 2, 3 と順番に探し、最初にヒットしたパーティメンバーの職業になる。 |
| SlotRow2 | SlotRow1 と同じ |
| SlotRow3 | SlotRow1 と同じ |
| SaveBottomHBox (HBoxContainer) | セーブスロット画面下部 |
| NextSlotButton (Button) | 次のセーブスロットを見る |
| SaveBackButton (Button) | 戻る |
| SaveConfirmDialog (ConfirmationDialog) | セーブ確認ポップアップ |
| SaveAlertDialog (AcceptDialog) | セーブ完了ポップアップ |
上記のようにノード構成を追加します。SaveLayout は非表示に設定してください。レイアウトは好みのものがあると思いますので、上記の DummySpace などを参考に調節をお願いします。
多言語対応用CSVファイルの追記

これまでの言語対応同様に res://data/localization.csv を開き、セーブ用のID,英語表記,日本語表記の項目を用意します。データを記述しCSV形式で保存したあと、インポートしなおすのを忘れないでください。
セーブデータ管理スクリプトの新規作成 (Autoload 設定)

# ==============================================================================
# SaveManager (セーブデータ管理クラス) - res://save_manager.gd
# ==============================================================================
extends Node
const SAVE_PATH_FORMAT = "user://save_slot_%d.save"
## 指定したスロットに現在のゲーム状態を保存する
func save_game(slot_no: int) -> bool:
# 1. 画面表示(ヘッダー用)の簡易情報の作成
var header_party = []
for slot in PartyManager.slots:
if slot != null:
header_party.append({
"name": slot.chara_name,
"level": slot.level,
"class": slot.chara_class,
"face_image_path": slot.face_image_path
})
break
# 2. ギルド名簿の実データをすべて保存
var serialized_guild_members = []
for member in GuildManager.registered_members:
if member != null:
serialized_guild_members.append(member.serialize())
# 3. パーティ枠の位置インデックスを保存
var party_slot_indices = []
for slot in PartyManager.slots:
if slot == null:
party_slot_indices.append(-1)
else:
var idx = GuildManager.registered_members.find(slot)
party_slot_indices.append(idx)
# 4. MenuWindow から現在の設定インデックス(0 or 1)を取得する
var saved_lang_index = 0
var saved_res_index = 0
# パス変更のリスクを避けるため、名前から MenuWindow ノードを安全に検索
var menu_window = get_tree().root.find_child("MenuWindow", true, false)
if menu_window:
if menu_window.language_button:
saved_lang_index = menu_window.language_button.selected
if menu_window.resolution_button:
saved_res_index = menu_window.resolution_button.selected
# 5. セーブデータ全体の構築
var save_data = {
"slot_no": slot_no,
"play_time": "12:34:56",
"party": header_party,
"guild_members": serialized_guild_members,
"party_slot_indices": party_slot_indices,
"config_language_index": saved_lang_index, # 【既存の設計に準拠】
"config_resolution_index": saved_res_index, # 【既存の設計に準拠】
"system_language": TranslationServer.get_locale() # 既存の互換性のため残す
}
# ファイルへの書き込み処理
var path = SAVE_PATH_FORMAT % slot_no
var file = FileAccess.open(path, FileAccess.WRITE)
if file == null:
print("[SaveManager] 失敗: ファイルを開けませんでした -> ", path)
return false
var json_string = JSON.stringify(save_data)
file.store_line(json_string)
file.close()
print("[SaveManager] 成功: スロットNo.", slot_no, " に保存しました。")
return true
## 指定したスロットのヘッダー情報を取得する
func get_slot_header(slot_no: int) -> Dictionary:
var path = SAVE_PATH_FORMAT % slot_no
if not FileAccess.file_exists(path):
return {"exists": false, "slot_no": slot_no}
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
return {"exists": false, "slot_no": slot_no}
var json_string = file.get_line()
file.close()
var json = JSON.new()
if json.parse(json_string) == OK:
var data = json.get_data()
data["exists"] = true
return data
return {"exists": false, "slot_no": slot_no}
まず FileSystem で右クリックをしセーブデータ管理スクリプト (save_manager.gd) を作成します。そのファイルには上記内容を記述してください。これはセーブ機能を実現するものです。

Project メニューから Project Settings を選択してください。

先ほど作成したセーブデータ管理スクリプト (save_manager.gd) を選択し、開きます。

Node Name が SaveManager となることを確認し、Add をクリックします。

追加された SaveManager の Global Variable の Enable にチェックが付いていることを確認し、ウィンドウを閉じます。
メニュー画面用コード修正
class_name MenuWindow
extends Control
signal menu_opened
signal menu_closed
# メインノードに設定変更をお願いするためのシグナル
signal language_change_requested(index: int)
signal resolution_change_requested(index: int)
# --- 右側メニューボタン参照 ---
@onready var items_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Items
@onready var spells_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Spells
@onready var skills_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Skills
@onready var equipment_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Equipment
@onready var formation_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Formation
@onready var status_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Status
@onready var tactics_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Tactics
@onready var config_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Config
@onready var save_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Save
# --- 左側基本UI参照 ---
@onready var menu_message_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/MessageContainer/MenuMessageLabel
@onready var gold_label: Label = $MainMenuLayout/MainHBox/RMainMenuLayout/GoldWindow/GoldHBox/GoldLabel
@onready var party_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout
@onready var l_main_menu_layout: Control = $MainMenuLayout/MainHBox/LMainMenuLayout
@onready var r_main_menu_layout: Control = $MainMenuLayout/MainHBox/RMainMenuLayout
@onready var front_row_party_member: HBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember
@onready var back_row_party_member: HBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember
# --- 設定(Config)画面用UI参照 ---
@onready var main_menu_layout: Control = $MainMenuLayout
@onready var config_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout
@onready var language_button: OptionButton = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/LanguageHBox/LanguageButton
@onready var resolution_button: OptionButton = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/ResolutionHBox/ResolutionButton
@onready var config_back_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/ConfigBackButton
# 設定画面内のテキストラベル参照
@onready var language_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/LanguageHBox/LanguageLabel
@onready var resolution_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/ResolutionHBox/ResolutionLabel
# --- パーティメンバーUI(1〜6)へのルート参照 ---
@onready var member_nodes: Array = [
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember/PartyMember1,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember/PartyMember2,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember/PartyMember3,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember/PartyMember4,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember/PartyMember5,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember/PartyMember6
]
# --- セーブ画面用UI参照 ---
@onready var save_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout
@onready var next_slot_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveBottomHBox/NextSlotButton
@onready var save_back_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveBottomHBox/SaveBackButton
@onready var save_lavel: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SaveLabel
# セーブスロット(3つ分)のボタン参照配列
@onready var slot_buttons: Array = [
$MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SlotRow1/SaveSlot1,
$MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SlotRow2/SaveSlot1,
$MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SlotRow3/SaveSlot1
]
# ダイアログ類(ポップアップ)の参照
@onready var save_confirm_dialog: ConfirmationDialog = $SaveConfirmDialog
@onready var save_alert_dialog: AcceptDialog = $SaveAlertDialog
# --- セーブ状態管理変数 ---
var _current_page_index: int = 0 # 0=スロット1-3, 1=スロット4-6, 2=スロット7-9
var _selected_slot_no: int = 1
var _last_town_focus_owner: Control = null
func _ready() -> void:
hide()
if config_layout: config_layout.hide()
if save_layout: save_layout.hide()
# 通常メニューボタンのシグナル接続
if items_button: items_button.pressed.connect(func(): _on_menu_button_pressed("ITEMS"))
if spells_button: spells_button.pressed.connect(func(): _on_menu_button_pressed("SPELLS"))
if skills_button: skills_button.pressed.connect(func(): _on_menu_button_pressed("SKILLS"))
if equipment_button: equipment_button.pressed.connect(func(): _on_menu_button_pressed("EQUIPMENT"))
if formation_button: formation_button.pressed.connect(func(): _on_menu_button_pressed("FORMATION"))
if status_button: status_button.pressed.connect(func(): _on_menu_button_pressed("STATUS"))
if tactics_button: tactics_button.pressed.connect(func(): _on_menu_button_pressed("TACTICS"))
if config_button: config_button.pressed.connect(func(): _on_menu_button_pressed("CONFIG"))
if save_button: save_button.pressed.connect(func(): _on_menu_button_pressed("SAVE"))
# メニューのフォーカスイベント接続
if items_button: items_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_ITEMS_EXPLANATION"))
if spells_button: spells_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_SPELLS_EXPLANATION"))
if skills_button: skills_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_SKILLS_EXPLANATION"))
if equipment_button: equipment_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_EQUIPMENT_EXPLANATION"))
if formation_button: formation_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_FORMATION_EXPLANATION"))
if status_button: status_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_STATUS_EXPLANATION"))
if tactics_button: tactics_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_TACTICS_EXPLANATION"))
if config_button: config_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_CONFIG_EXPLANATION"))
if save_button: save_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_SAVE_EXPLANATION"))
_setup_config_ui()
_setup_save_ui()
setup_party_layout_sizes()
func _input(event: InputEvent) -> void:
if not visible:
if event.is_action_pressed("ui_select"):
get_viewport().set_input_as_handled()
open_menu()
return
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
if config_layout and config_layout.visible:
_close_config_screen()
elif save_layout and save_layout.visible:
_close_save_screen()
else:
close_menu()
func open_menu() -> void:
update_all_strings()
_update_party_display()
_hide_all_sub_layouts()
if party_layout: party_layout.show()
_last_town_focus_owner = get_viewport().gui_get_focus_owner()
if _last_town_focus_owner:
_last_town_focus_owner.release_focus()
show()
await get_tree().process_frame
if items_button:
items_button.grab_focus()
menu_opened.emit()
func _open_config_screen() -> void:
if not config_layout: return
_hide_all_sub_layouts()
config_layout.show()
await get_tree().process_frame
language_button.grab_focus()
func _close_config_screen() -> void:
if not config_layout: return
_hide_all_sub_layouts()
if party_layout: party_layout.show()
await get_tree().process_frame
if config_button: config_button.grab_focus()
func _close_save_screen() -> void:
if not save_layout: return
_hide_all_sub_layouts()
if party_layout: party_layout.show()
await get_tree().process_frame
if save_button:
save_button.grab_focus()
func _back_to_main_menu_after_save() -> void:
_close_save_screen()
func close_menu() -> void:
hide()
if _last_town_focus_owner and is_instance_valid(_last_town_focus_owner) and _last_town_focus_owner.is_inside_tree():
_last_town_focus_owner.grab_focus()
_last_town_focus_owner = null
menu_closed.emit()
## メインメニューおよび現在の説明文を最新の言語に再翻訳するパブリック関数
func update_all_strings() -> void:
if items_button: items_button.text = tr("MAIN_MENU_ITEMS")
if spells_button: spells_button.text = tr("MAIN_MENU_SPELLS")
if skills_button: skills_button.text = tr("MAIN_MENU_SKILLS")
if equipment_button: equipment_button.text = tr("MAIN_MENU_EQUIPMENT")
if formation_button: formation_button.text = tr("MAIN_MENU_FORMATION")
if status_button: status_button.text = tr("MAIN_MENU_STATUS")
if tactics_button: tactics_button.text = tr("MAIN_MENU_TACTICS")
if config_button: config_button.text = tr("MAIN_MENU_CONFIG")
if save_button: save_button.text = tr("MAIN_MENU_SAVE")
if gold_label: gold_label.text = tr("MAIN_MENU_GOLD")
if language_label: language_label.text = tr("MENU_SETTINGS_LANGUAGE_LABEL")
if resolution_label: resolution_label.text = tr("MENU_SETTINGS_RESOLUTION_LABEL")
if config_back_button: config_back_button.text = tr("MENU_SETTINGS_BACKBUTTON_LABEL")
# セーブ画面のテキスト
if next_slot_button: next_slot_button.text = tr("MENU_SAVE_NEXT_PAGE")
if save_back_button: save_back_button.text = tr("MENU_SAVE_BACK")
if save_lavel: save_lavel.text = tr("MAIN_MENU_SAVE")
# セーブダイアログのウィンドウタイトルのローカライズ
if save_confirm_dialog:
save_confirm_dialog.title = tr("MENU_SAVE_CONFIRM_DIALOG_TITLE")
if save_alert_dialog:
save_alert_dialog.title = tr("MENU_SAVE_ALERT_DIALOG_TITLE")
if config_layout and config_layout.visible:
_update_explanation("MAIN_MENU_CONFIG_EXPLANATION")
elif save_layout and save_layout.visible:
_update_explanation("MENU_SAVE_MESSAGE_EXPLANATION")
elif config_button and config_button.has_focus():
_update_explanation("MAIN_MENU_CONFIG_EXPLANATION")
_update_party_display()
# セーブ画面表示中ならスロット上のテキストも再翻訳して更新
if save_layout and save_layout.visible:
_render_save_slots()
func _on_menu_button_pressed(button_id: String) -> void:
_hide_all_sub_layouts()
match button_id:
"CONFIG":
_open_config_screen()
"SAVE":
_open_save_screen()
"ITEMS", "SPELLS", "SKILLS", "EQUIPMENT", "FORMATION", "STATUS", "TACTICS":
if party_layout: party_layout.show()
print("[MenuWindow] 選択されたメニュー: ", button_id)
func _hide_all_sub_layouts() -> void:
if party_layout: party_layout.hide()
if config_layout: config_layout.hide()
if save_layout: save_layout.hide()
func _update_explanation(loc_id: String) -> void:
if menu_message_label:
menu_message_label.text = tr(loc_id)
# ==============================================================================
# メニュー画面全体のフルサイズ固定(全画面化)
# ==============================================================================
func set_menu_full_size() -> void:
if not main_menu_layout: return
self.custom_minimum_size = Vector2.ZERO
main_menu_layout.custom_minimum_size = Vector2.ZERO
var target_screen_size := Vector2(1280, 720)
self.set_anchors_preset(Control.PRESET_TOP_LEFT)
self.position = Vector2.ZERO
main_menu_layout.set_anchors_preset(Control.PRESET_TOP_LEFT)
main_menu_layout.position = Vector2.ZERO
self.size = target_screen_size
main_menu_layout.size = target_screen_size
self.custom_minimum_size = target_screen_size
main_menu_layout.custom_minimum_size = target_screen_size
# ==============================================================================
# 設定画面(Config)のコントロールロジック
# ==============================================================================
func _setup_config_ui() -> void:
if not language_button or not resolution_button or not config_back_button: return
language_button.clear()
language_button.add_item("English", 0)
language_button.add_item("日本語", 1)
language_button.select(0)
language_button.item_selected.connect(func(id): language_change_requested.emit(id))
resolution_button.clear()
resolution_button.add_item("1280x720", 0)
resolution_button.add_item("1920x1080", 1)
resolution_button.select(0)
resolution_button.item_selected.connect(func(id): resolution_change_requested.emit(id))
config_back_button.pressed.connect(_close_config_screen)
# ==============================================================================
# セーブ画面(Save)のコントロールロジック
# ==============================================================================
func _setup_save_ui() -> void:
if not next_slot_button or not save_back_button: return
next_slot_button.pressed.connect(_on_next_slot_pressed)
save_back_button.pressed.connect(_close_save_screen)
for i in range(3):
var btn = slot_buttons[i]
if btn:
btn.pressed.connect(func(): _on_slot_selected(i))
if save_confirm_dialog:
save_confirm_dialog.confirmed.connect(_execute_save)
save_confirm_dialog.canceled.connect(func(): slot_buttons[0].grab_focus())
if save_alert_dialog:
save_alert_dialog.confirmed.connect(_back_to_main_menu_after_save)
func _open_save_screen() -> void:
if not save_layout or not party_layout: return
party_layout.hide()
if config_layout: config_layout.hide()
save_layout.show()
_current_page_index = 0
_update_explanation("MENU_SAVE_MESSAGE_EXPLANATION")
_render_save_slots()
await get_tree().process_frame
if slot_buttons[0]:
slot_buttons[0].grab_focus()
## 現在のページインデックス(0~2)に応じたセーブスロットデータを描画する
func _render_save_slots() -> void:
var start_slot = (_current_page_index * 3) + 1
for i in range(3):
var slot_no = start_slot + i
var btn: Button = slot_buttons[i]
if not btn: continue
var header = SaveManager.get_slot_header(slot_no)
var parent_node = btn.get_parent()
var lbl_no: Label = null
if parent_node:
lbl_no = parent_node.find_child("SlotNoLabel", true, false)
var rect_face: TextureRect = btn.find_child("SlotAppearance", true, false)
var lbl_time: Label = btn.find_child("SlotPlayTimeLabel", true, false)
var lbl_name: Label = btn.find_child("SlotNameLabel", true, false)
var lbl_lv: Label = btn.find_child("SlotLvLabel", true, false)
var lbl_class: Label = btn.find_child("SlotClassLabel", true, false)
if lbl_no: lbl_no.text = "No.%d" % slot_no
if not header["exists"]:
# データなし(新規保存用表示)
if lbl_time: lbl_time.text = "--:--:--"
# 【ローカライズ適用】
if lbl_name: lbl_name.text = tr("MENU_SAVE_SLOT_EMPTY")
if lbl_lv: lbl_lv.text = ""
if lbl_class: lbl_class.text = ""
if rect_face: rect_face.texture = null
else:
# データあり
if lbl_time: lbl_time.text = header.get("play_time", "00:00:00")
var party_data = header.get("party", [])
if party_data.size() > 0:
var chara = party_data[0]
if lbl_name: lbl_name.text = chara["name"]
if lbl_lv: lbl_lv.text = "Lv.%d" % chara["level"]
if lbl_class: lbl_class.text = tr(chara["class"])
if rect_face and chara["face_image_path"] and FileAccess.file_exists(chara["face_image_path"]):
rect_face.texture = load(chara["face_image_path"])
elif rect_face:
rect_face.texture = null
else:
# パーティがいない状態でセーブされたデータ
if lbl_name: lbl_name.text = tr("MENU_SAVE_SLOT_NO_PARTY")
if lbl_lv: lbl_lv.text = ""
if lbl_class: lbl_class.text = ""
if rect_face: rect_face.texture = null
func _on_next_slot_pressed() -> void:
_current_page_index = (_current_page_index + 1) % 3
_render_save_slots()
await get_tree().process_frame
if slot_buttons[0]:
slot_buttons[0].grab_focus()
func _on_slot_selected(local_index: int) -> void:
_selected_slot_no = (_current_page_index * 3) + 1 + local_index
var header = SaveManager.get_slot_header(_selected_slot_no)
if not save_confirm_dialog: return
# プレースホルダー付き文字列の動的合成
if not header["exists"]:
save_confirm_dialog.dialog_text = tr("MENU_SAVE_CONFIRM_DIALOG_NEW_DATA") % _selected_slot_no
else:
save_confirm_dialog.dialog_text = tr("MENU_SAVE_CONFIRM_DIALOG_OVERWRITE") % _selected_slot_no
# --- ポップアップを表示する前に、Cancel ボタンへ強制フォーカス ---
save_confirm_dialog.popup_centered()
# popup された直後のフレームでフォーカスを移さないと効かない場合があるため
# 安全に 1フレーム待ってから grab_focus() させるか、直接ボタンを奪います
var cancel_btn = save_confirm_dialog.get_cancel_button()
if cancel_btn:
cancel_btn.grab_focus()
func _execute_save() -> void:
var success = SaveManager.save_game(_selected_slot_no)
if not save_alert_dialog: return
# プレースホルダー付き文字列の動的合成
if success:
save_alert_dialog.dialog_text = tr("MENU_SAVE_ALERT_DIALOG_SUCCESS") % _selected_slot_no
else:
save_alert_dialog.dialog_text = tr("MENU_SAVE_ALERT_DIALOG_FAILURE") % _selected_slot_no
save_alert_dialog.popup_centered()
# ==============================================================================
# サイズ調整・パーティ表示・その他(変更なし)
# ==============================================================================
func set_config_layout_size(resolution_index: int) -> void:
if not config_layout: return
var target_config_size := Vector2(320, 180)
match resolution_index:
0: target_config_size = Vector2(320, 180)
1: target_config_size = Vector2(480, 270)
config_layout.custom_minimum_size = target_config_size
config_layout.size = target_config_size
func set_main_layouts_size() -> void:
if not l_main_menu_layout or not r_main_menu_layout: return
var left_width := 1120.0
var right_width := 128.0
l_main_menu_layout.custom_minimum_size = Vector2(left_width, 0)
l_main_menu_layout.size.x = left_width
r_main_menu_layout.custom_minimum_size = Vector2(right_width, 0)
r_main_menu_layout.size.x = right_width
func _update_party_display() -> void:
for i in range(6):
var member_panel = member_nodes[i]
if not member_panel: continue
member_panel.show()
var data = PartyManager.slots[i]
var label_name: Label = member_panel.find_child("Name", true, false)
var label_lv: Label = member_panel.find_child("Lv", true, false)
var rect_appearance: TextureRect = member_panel.find_child("Appearance", true, false)
var label_class: Label = member_panel.find_child("Class", true, false)
var label_hp: Label = member_panel.find_child("HPLabel", true, false)
var bar_hp: ProgressBar = member_panel.find_child("HPBar", true, false)
var label_mp: Label = member_panel.find_child("MPLabel", true, false)
var bar_mp: ProgressBar = member_panel.find_child("MPBar", true, false)
if data == null:
if label_name: label_name.text = ""
if label_lv: label_lv.text = ""
if label_class: label_class.text = ""
if label_hp: label_hp.text = ""
if label_mp: label_mp.text = ""
if rect_appearance: rect_appearance.texture = null
if bar_hp: bar_hp.hide()
if bar_mp: bar_mp.hide()
else:
if bar_hp: bar_hp.show()
if bar_mp: bar_mp.show()
if label_name: label_name.text = data.chara_name
if label_lv: label_lv.text = "Lv%d" % data.level
if label_class: label_class.text = data.get_display_class()
if label_hp: label_hp.text = "HP %d/%d" % [data.current_hp, data.max_hp]
if label_mp: label_mp.text = "MP %d/%d" % [data.current_mp, data.max_mp]
if rect_appearance:
if data.face_image_path and FileAccess.file_exists(data.face_image_path):
rect_appearance.texture = load(data.face_image_path)
else:
rect_appearance.texture = null
rect_appearance.custom_minimum_size = Vector2(160, 160)
rect_appearance.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
if bar_hp:
bar_hp.min_value = 0
bar_hp.max_value = data.max_hp
bar_hp.value = data.current_hp
if bar_mp:
bar_mp.min_value = 0
bar_mp.max_value = data.max_mp
bar_mp.value = data.current_mp
func setup_party_layout_sizes() -> void:
if party_layout and front_row_party_member and back_row_party_member:
party_layout.custom_minimum_size = Vector2(960, 0)
party_layout.size.x = 960
front_row_party_member.custom_minimum_size = Vector2(960, 0)
back_row_party_member.custom_minimum_size = Vector2(960, 0)
for member_panel in member_nodes:
if not member_panel: continue
member_panel.custom_minimum_size = Vector2(320, 240)
member_panel.size = Vector2(320, 240)
var member_hbox = member_panel.get_node_or_null("MemberHBox1")
if not member_hbox: member_hbox = member_panel.find_child("MemberHBox*", true, false)
if member_hbox:
var l_part = member_hbox.get_node_or_null("LPartyMember1")
if not l_part: l_part = member_hbox.find_child("LPartyMember*", true, false)
if l_part:
l_part.custom_minimum_size = Vector2(160, 0)
l_part.size.x = 160
var r_part = member_hbox.get_node_or_null("RPartyMember1")
if not r_part: r_part = member_hbox.find_child("RPartyMember*", true, false)
if r_part:
r_part.custom_minimum_size = Vector2(160, 0)
r_part.size.x = 160
if save_layout:
# 大元の SaveLayout の横幅を 720px に固定 (縦幅は自動)
save_layout.custom_minimum_size = Vector2(420, 0)
save_layout.size.x = 420
# 3つのセーブスロットボタンの横幅を 720px に強制ロックし、内部パーツのサイズも固定する
for btn in slot_buttons:
if btn is Button:
var current_height = btn.size.y if btn.size.y > 0 else 120.0
btn.custom_minimum_size = Vector2(420, current_height)
btn.size = Vector2(420, current_height)
# --- スロット内部の左右コンテナの横幅を固定長にする ---
var slot_left = btn.find_child("SlotLeft", true, false)
if slot_left and slot_left is Control:
# 顔グラフィック領域(左側)の横幅を 160px に固定
slot_left.custom_minimum_size = Vector2(160, 0)
slot_left.size.x = 160
var slot_right = btn.find_child("SlotRight", true, false)
if slot_right and slot_right is Control:
# テキスト領域(右側)の横幅を残り全体の 520px(720 - 160 - 余白等)に完全固定
slot_right.custom_minimum_size = Vector2(260, 0)
slot_right.size.x = 260
print("[MenuWindow] セーブレイアウトおよび内部パーツの横幅を完全固定しました。")
メニュー画面用スクリプト (menu_window.gd) を上記コードに修正します。これでゲームデータのセーブができるようになりました。
ロード機能

現時点ではタイトル画面は実装されていません。そのため、ロード機能はメニュー画面の設定(Config) に追加します。ここでは、上記画像のように「ゲームデータのロード」ボタンを新設するところから行います。
ノード構成
- MenuWindow (Control)
- MainMenuLayout (PanelContainer)
- MainHBox (HBoxContainer)
- LMainMenuLayout (VBoxContainer)
- DummySpaceL (Control)
- MessageContainer (PanelContainer)
- MenuMessageLabel (Label)
- PartyLayout (VBoxContainer)
- (省略)
- ConfigLayout (VBoxContainer)
- DummySpaceConfig (Control)
- LanguageHBox (HBoxContainer)
- (省略)
- ResolutionHBox (HBoxContainer)
- (省略)
- LoadButtonHBox (HBoxContainer) ※ 新規
- LoadButton (Button) ※ 新規
- ConfigBackButton (HBoxContainer)
- SaveLayout (VBoxContainer)
- (省略)
- LoadLayout (VBoxContainer) ※ 新規
- LoadSlotContainer (VBoxContainer) ※ 新規
- DummySpaceLoadSlot (Control) ※ 新規
- LoadLabel (Label) ※ 新規
- LoadSlotRow1 (HBoxContainer) ※ 新規
- LoadSlotNoLabel (Label) ※ 新規
- LoadSlot1 (Button) ※ 新規
- SlotHBox (HBoxContainer) ※ 新規
- DummySpaceSlot (Control) ※ 新規
- SlotLeft (HBoxContainer) ※ 新規
- SlotAppearance (TextureRect) ※ 新規
- SlotRight (VBoxContainer) ※ 新規
- SlotPlayTimeLabel (Label) ※ 新規
- SlotCharaInfoLabel (Label) ※ 新規
- SlotLvLabel (Label) ※ 新規
- SlotClassLabel (Label) ※ 新規
- SlotHBox (HBoxContainer) ※ 新規
- LoadSlotRow2 (SaveSlot1 と同じ) ※ 新規
- LoadSlotRow3 (SaveSlot1 と同じ) ※ 新規
- LoadBottomHBox (HBoxContainer) ※ 新規
- LoadNextSlotButton (Button) ※ 新規
- LoadBackButton (Button) ※ 新規
- LoadSlotContainer (VBoxContainer) ※ 新規
- RMainMenuLayout (VBoxContainer)
- (省略)
- SaveConfirmDialog (ConfirmationDialog)
- SaveAlertDialog (AcceptDialog)
- LoadConfirmDialog (ConfirmationDialog) ※ 新規
- LoadAlertDialog (AcceptDialog) ※ 新規
- LMainMenuLayout (VBoxContainer)
- MainHBox (HBoxContainer)
- MainMenuLayout (PanelContainer)
| LoadButtonHBox (HBoxContainer) | メニューの設定にあるロードボタンのレイアウトを考えて HBoxConatiner で作成。 |
| LoadButton (Button) | メニューの設定にあるロードボタン |
| LoadLayout (VBoxContainer) | セーブ画面用レイアウト。非表示 |
| LoadSlotContainer (VBoxContainer) | セーブスロット画面中央部 |
| DummySpaceLoadSlot (Control) | スペース調整用。Custom Minimum Size で調整 |
| LoadLabel (Label) | ロード |
| LoadSlotRow1 (HBoxContainer) | スロット1行目 |
| LoadSlotNoLabel (Label) | スロット番号。No.1 ~ 9 |
| LoadSlot1 (Button) | スロット1。ボタンで実装 |
| SlotHBox (HBoxContainer) | ロードスロット内部を左右に分ける |
| DummySpaceLoadSlot (Control) | スペース調整用。Custom Minimum Size で調整 |
| SlotLeft (HBoxContainer) | セーブスロット内部左側 |
| SlotAppearance (TextureRect) | 前衛1 (PartyMember1) の見た目画像 |
| SlotRight (VBoxContainer) | スロット内部右側 |
| SlotPlayTimeLabel (Label) | プレイ時間 |
| SlotNameLabel (Label) | 前衛1 (PartyMember1) の名前 |
| SlotLvLabel (Label) | 前衛1 (PartyMember1) のレベル |
| SlotClassLabel (Label) | 前衛1 (PartyMember1) の職業 |
| LoadSlotRow2 | SlotRow1 と同じ |
| LoadSlotRow3 | SlotRow1 と同じ |
| LoadBottomHBox (HBoxContainer) | スロット画面下部 |
| NextSlotButton (Button) | 次のスロットを見る |
| LoadBackButton (Button) | 戻る |
| LoadConfirmDialog (ConfirmationDialog) | ロード確認ポップアップ |
| LoadAlertDialog (AcceptDialog) | ロード完了ポップアップ |
上記のようにノード構成を追加します。LoadLayout は非表示に設定してください。
多言語対応用CSVファイルの追記

これまでの言語対応同様に res://data/localization.csv を開き、ロード用のID,英語表記,日本語表記の項目を用意します。データを記述しCSV形式で保存したあと、インポートしなおすのを忘れないでください。
ロードデータ管理スクリプトの新規作成 (Autoload 設定)
# ==============================================================================
# LoadManager (ロードデータ管理クラス) - res://load_manager.gd
# ==============================================================================
extends Node
const SAVE_PATH_FORMAT = "user://save_slot_%d.save"
signal load_completed
## 指定されたスロットの存在確認を行い、再起動の準備をする
func load_game(slot_no: int) -> bool:
var path = SAVE_PATH_FORMAT % slot_no
if not FileAccess.file_exists(path):
print("[LoadManager] 失敗: スロット ", slot_no, " にファイルが存在しません。")
return false
print("[LoadManager] スロットNo.", slot_no, " のロードを開始します。シーンをリロードします。")
var main_scene_path = ProjectSettings.get_setting("application/run/main_scene")
if main_scene_path and ResourceLoader.exists(main_scene_path):
get_tree().change_scene_to_file(main_scene_path)
else:
get_tree().reload_current_scene()
_wait_and_execute_load(slot_no)
return true
func _wait_and_execute_load(slot_no: int) -> void:
await get_tree().process_frame
await get_tree().physics_frame
_execute_actual_load(slot_no)
## 内部的なファイル読み込みと実データ復元ロジック
func _execute_actual_load(slot_no: int) -> void:
var path = SAVE_PATH_FORMAT % slot_no
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
print("[LoadManager] 実ロード失敗: ファイルが開けません。")
return
var json_string = file.get_line()
file.close()
var json = JSON.new()
if json.parse(json_string) != OK:
print("[LoadManager] 実ロード失敗: JSONパースエラー。")
return
var save_data = json.get_data()
# ==========================================================================
# 1. ギルド名簿の完全総入れ替え
# ==========================================================================
var new_registered_members: Array[CharaData] = []
var guild_members_data = save_data.get("guild_members", [])
for member_data in guild_members_data:
if member_data != null:
new_registered_members.append(CharaData.deserialize(member_data))
GuildManager.registered_members = new_registered_members
# ==========================================================================
# 2. パーティ配置の復元
# ==========================================================================
var party_slot_indices = save_data.get("party_slot_indices", [])
for i in range(PartyManager.slots.size()):
if i < party_slot_indices.size():
var member_idx = party_slot_indices[i]
if member_idx >= 0 and member_idx < GuildManager.registered_members.size():
PartyManager.slots[i] = GuildManager.registered_members[member_idx]
else:
PartyManager.slots[i] = null
else:
PartyManager.slots[i] = null
# ==========================================================================
# 3. 既存の MenuWindow のシグナル設計を利用した設定の復元
# ==========================================================================
var menu_window = get_tree().root.find_child("MenuWindow", true, false)
if menu_window:
var lang_idx = save_data.get("config_language_index", 0)
var res_idx = save_data.get("config_resolution_index", 0)
# UIの選択状態をセーブ時のインデックスに合わせる
if menu_window.language_button:
menu_window.language_button.select(lang_idx)
if menu_window.resolution_button:
menu_window.resolution_button.select(res_idx)
# MenuWindow が定義しているシグナルをそのままエミットし、
# メインノード側に本来の言語変更・解像度変更の処理を要求する
menu_window.language_change_requested.emit(lang_idx)
menu_window.resolution_change_requested.emit(res_idx)
# メニュー内のテキスト表記を最新言語に更新させる
if menu_window.has_method("update_all_strings"):
menu_window.update_all_strings()
print("[LoadManager] MenuWindow の既存シグナルを介して設定を正常に復元しました。")
print("[LoadManager] ロード完了。すべてのデータを復元しました。")
load_completed.emit()
セーブデータ管理スクリプト同様にロードデータ管理スクリプトも作成します。FileSystem に load_manager.gd を作成、上記内容を記述し、Autoload 設定を行ってください。(Autoload 設定方法については、上記のセーブデータ管理スクリプトをご参照ください)
メニュー画面用コード修正
class_name MenuWindow
extends Control
signal menu_opened
signal menu_closed
# メインノードに設定変更をお願いするためのシグナル
signal language_change_requested(index: int)
signal resolution_change_requested(index: int)
# --- 右側メニューボタン参照 ---
@onready var items_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Items
@onready var spells_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Spells
@onready var skills_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Skills
@onready var equipment_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Equipment
@onready var formation_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Formation
@onready var status_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Status
@onready var tactics_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Tactics
@onready var config_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Config
@onready var save_button: Button = $MainMenuLayout/MainHBox/RMainMenuLayout/MenuList/ButtonList/Save
# --- 左側基本UI参照 ---
@onready var menu_message_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/MessageContainer/MenuMessageLabel
@onready var gold_label: Label = $MainMenuLayout/MainHBox/RMainMenuLayout/GoldWindow/GoldHBox/GoldLabel
@onready var party_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout
@onready var l_main_menu_layout: Control = $MainMenuLayout/MainHBox/LMainMenuLayout
@onready var r_main_menu_layout: Control = $MainMenuLayout/MainHBox/RMainMenuLayout
@onready var front_row_party_member: HBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember
@onready var back_row_party_member: HBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember
# --- 設定(Config)画面用UI参照 ---
@onready var main_menu_layout: Control = $MainMenuLayout
@onready var config_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout
@onready var language_button: OptionButton = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/LanguageHBox/LanguageButton
@onready var resolution_button: OptionButton = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/ResolutionHBox/ResolutionButton
@onready var load_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/LoadButtonHBox/LoadButton # 【新設】
@onready var config_back_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/ConfigBackButton
# 設定画面内のテキストラベル参照
@onready var language_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/LanguageHBox/LanguageLabel
@onready var resolution_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/ConfigLayout/ResolutionHBox/ResolutionLabel
# --- パーティメンバーUI(1〜6)へのルート参照 ---
@onready var member_nodes: Array = [
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember/PartyMember1,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember/PartyMember2,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/FrontRowPartyMember/PartyMember3,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember/PartyMember4,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember/PartyMember5,
$MainMenuLayout/MainHBox/LMainMenuLayout/PartyLayout/BackRowPartyMember/PartyMember6
]
# --- セーブ画面用UI参照 ---
@onready var save_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout
@onready var next_slot_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveBottomHBox/NextSlotButton
@onready var save_back_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveBottomHBox/SaveBackButton
@onready var save_lavel: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SaveLabel
# セーブスロット(3つ分)のボタン参照配列
@onready var slot_buttons: Array = [
$MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SlotRow1/SaveSlot1,
$MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SlotRow2/SaveSlot1,
$MainMenuLayout/MainHBox/LMainMenuLayout/SaveLayout/SaveSlotContainer/SlotRow3/SaveSlot1
]
# --- ロード画面用UI参照 ---
@onready var load_layout: VBoxContainer = $MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout
@onready var load_next_slot_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout/LoadBottomHBox/LoadNextSlotButton
@onready var load_back_button: Button = $MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout/LoadBottomHBox/LoadBackButton
@onready var load_label: Label = $MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout/LoadSlotContainer/LoadLabel
# ロードスロット(3つ分)のボタン参照配列
@onready var load_slot_buttons: Array = [
$MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout/LoadSlotContainer/LoadSlotRow1/LoadSlot1,
$MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout/LoadSlotContainer/LoadSlotRow2/LoadSlot1,
$MainMenuLayout/MainHBox/LMainMenuLayout/LoadLayout/LoadSlotContainer/LoadSlotRow3/LoadSlot1
]
# ダイアログ類(ポップアップ)の参照
@onready var save_confirm_dialog: ConfirmationDialog = $SaveConfirmDialog
@onready var save_alert_dialog: AcceptDialog = $SaveAlertDialog
@onready var load_confirm_dialog: ConfirmationDialog = $LoadConfirmDialog # 【新設】
@onready var load_alert_dialog: AcceptDialog = $LoadAlertDialog # 【新設】
# --- 状態管理変数 ---
var _current_page_index: int = 0 # 0=1-3, 1=4-6, 2=7-9 (セーブ用)
var _load_current_page_index: int = 0 # 0=1-3, 1=4-6, 2=7-9 (ロード用) 【新設】
var _selected_slot_no: int = 1
var _selected_load_slot_no: int = 1 # 【新設】
var _last_town_focus_owner: Control = null
var _load_success_flag: bool = false # ロード成功後に再起動を呼ぶためのフラグ 【新設】
func _ready() -> void:
hide()
if config_layout: config_layout.hide()
if save_layout: save_layout.hide()
if load_layout: load_layout.hide()
# 【追加】LoadManager が実ロードを完了したら、即座にUI表示を最新化する
LoadManager.load_completed.connect(func():
_update_party_display()
# ついでにロード画面を閉じて、メインメニューに戻してあげる
_hide_all_sub_layouts()
if party_layout: party_layout.show()
)
# 通常メニューボタンのシグナル接続
if items_button: items_button.pressed.connect(func(): _on_menu_button_pressed("ITEMS"))
if spells_button: spells_button.pressed.connect(func(): _on_menu_button_pressed("SPELLS"))
if skills_button: skills_button.pressed.connect(func(): _on_menu_button_pressed("SKILLS"))
if equipment_button: equipment_button.pressed.connect(func(): _on_menu_button_pressed("EQUIPMENT"))
if formation_button: formation_button.pressed.connect(func(): _on_menu_button_pressed("FORMATION"))
if status_button: status_button.pressed.connect(func(): _on_menu_button_pressed("STATUS"))
if tactics_button: tactics_button.pressed.connect(func(): _on_menu_button_pressed("TACTICS"))
if config_button: config_button.pressed.connect(func(): _on_menu_button_pressed("CONFIG"))
if save_button: save_button.pressed.connect(func(): _on_menu_button_pressed("SAVE"))
# メニューのフォーカスイベント接続
if items_button: items_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_ITEMS_EXPLANATION"))
if spells_button: spells_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_SPELLS_EXPLANATION"))
if skills_button: skills_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_SKILLS_EXPLANATION"))
if equipment_button: equipment_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_EQUIPMENT_EXPLANATION"))
if formation_button: formation_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_FORMATION_EXPLANATION"))
if status_button: status_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_STATUS_EXPLANATION"))
if tactics_button: tactics_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_TACTICS_EXPLANATION"))
if config_button: config_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_CONFIG_EXPLANATION"))
if save_button: save_button.focus_entered.connect(func(): _update_explanation("MAIN_MENU_SAVE_EXPLANATION"))
_setup_config_ui()
_setup_save_ui()
_setup_load_ui() # 【新設】
setup_party_layout_sizes()
func _input(event: InputEvent) -> void:
if not visible:
if event.is_action_pressed("ui_select"):
get_viewport().set_input_as_handled()
open_menu()
return
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
if load_layout and load_layout.visible: # 【新設:ロード画面から設定画面へ戻る】
_close_load_screen()
elif config_layout and config_layout.visible:
_close_config_screen()
elif save_layout and save_layout.visible:
_close_save_screen()
else:
close_menu()
func open_menu() -> void:
update_all_strings()
_update_party_display()
_hide_all_sub_layouts()
if party_layout: party_layout.show()
_last_town_focus_owner = get_viewport().gui_get_focus_owner()
if _last_town_focus_owner:
_last_town_focus_owner.release_focus()
show()
await get_tree().process_frame
if items_button:
items_button.grab_focus()
menu_opened.emit()
func _open_config_screen() -> void:
if not config_layout: return
_hide_all_sub_layouts()
config_layout.show()
await get_tree().process_frame
language_button.grab_focus()
func _close_config_screen() -> void:
if not config_layout: return
_hide_all_sub_layouts()
if party_layout: party_layout.show()
await get_tree().process_frame
if config_button: config_button.grab_focus()
func _close_save_screen() -> void:
if not save_layout: return
_hide_all_sub_layouts()
if party_layout: party_layout.show()
await get_tree().process_frame
if save_button:
save_button.grab_focus()
func _back_to_main_menu_after_save() -> void:
_close_save_screen()
# --- 【新設】ロード画面用コントロールメソッド ---
func _open_load_screen() -> void:
if not load_layout: return
_hide_all_sub_layouts()
load_layout.show()
_load_current_page_index = 0
_update_explanation("MENU_LOAD_MESSAGE_EXPLANATION") # 「ロードするデータを選択してください」
_render_load_slots()
await get_tree().process_frame
if load_slot_buttons[0]:
load_slot_buttons[0].grab_focus()
func _close_load_screen() -> void:
if not load_layout: return
_hide_all_sub_layouts()
if config_layout: config_layout.show() # 設定画面に戻る仕様
await get_tree().process_frame
if load_button: load_button.grab_focus()
func close_menu() -> void:
hide()
if _last_town_focus_owner and is_instance_valid(_last_town_focus_owner) and _last_town_focus_owner.is_inside_tree():
_last_town_focus_owner.grab_focus()
_last_town_focus_owner = null
menu_closed.emit()
## メインメニューおよび現在の説明文を最新の言語に再翻訳するパブリック関数
func update_all_strings() -> void:
if items_button: items_button.text = tr("MAIN_MENU_ITEMS")
if spells_button: spells_button.text = tr("MAIN_MENU_SPELLS")
if skills_button: skills_button.text = tr("MAIN_MENU_SKILLS")
if equipment_button: equipment_button.text = tr("MAIN_MENU_EQUIPMENT")
if formation_button: formation_button.text = tr("MAIN_MENU_FORMATION")
if status_button: status_button.text = tr("MAIN_MENU_STATUS")
if tactics_button: tactics_button.text = tr("MAIN_MENU_TACTICS")
if config_button: config_button.text = tr("MAIN_MENU_CONFIG")
if save_button: save_button.text = tr("MAIN_MENU_SAVE")
if gold_label: gold_label.text = tr("MAIN_MENU_GOLD")
if language_label: language_label.text = tr("MENU_SETTINGS_LANGUAGE_LABEL")
if resolution_label: resolution_label.text = tr("MENU_SETTINGS_RESOLUTION_LABEL")
if load_button: load_button.text = tr("MENU_SETTINGS_LOADBUTTON_LABEL") # 【新設:ゲームデータのロード】
if config_back_button: config_back_button.text = tr("MENU_SETTINGS_BACKBUTTON_LABEL")
# セーブ画面のテキスト
if next_slot_button: next_slot_button.text = tr("MENU_SAVE_NEXT_PAGE")
if save_back_button: save_back_button.text = tr("MENU_SAVE_BACK")
if save_lavel: save_lavel.text = tr("MAIN_MENU_SAVE")
# ロード画面のテキスト 【新設】
if load_next_slot_button: load_next_slot_button.text = tr("MENU_SAVE_NEXT_PAGE") # 「次のスロットを見る」を共通流用
if load_back_button: load_back_button.text = tr("MENU_SETTINGS_BACKBUTTON_LABEL") # 「戻る」を流用
if load_label: load_label.text = tr("MENU_LOAD") # 「ロード」タイトル
# ダイアログのウィンドウタイトルのローカライズ
if save_confirm_dialog:
save_confirm_dialog.title = tr("MENU_SAVE_CONFIRM_DIALOG_TITLE")
if save_alert_dialog:
save_alert_dialog.title = tr("MENU_SAVE_ALERT_DIALOG_TITLE")
if load_confirm_dialog: # 【新設】
load_confirm_dialog.title = tr("MENU_LOAD_CONFIRM_DIALOG_TITLE")
if load_alert_dialog: # 【新設】
load_alert_dialog.title = tr("MENU_LOAD_ALERT_DIALOG_TITLE")
if config_layout and config_layout.visible:
_update_explanation("MAIN_MENU_CONFIG_EXPLANATION")
elif save_layout and save_layout.visible:
_update_explanation("MENU_SAVE_MESSAGE_EXPLANATION")
elif load_layout and load_layout.visible: # 【新設】
_update_explanation("MENU_LOAD_MESSAGE_EXPLANATION")
elif config_button and config_button.has_focus():
_update_explanation("MAIN_MENU_CONFIG_EXPLANATION")
_update_party_display()
# セーブ画面・ロード画面表示中ならスロット上のテキストも再翻訳して更新
if save_layout and save_layout.visible:
_render_save_slots()
elif load_layout and load_layout.visible: # 【新設】
_render_load_slots()
func _on_menu_button_pressed(button_id: String) -> void:
_hide_all_sub_layouts()
match button_id:
"CONFIG":
_open_config_screen()
"SAVE":
_open_save_screen()
"ITEMS", "SPELLS", "SKILLS", "EQUIPMENT", "FORMATION", "STATUS", "TACTICS":
if party_layout: party_layout.show()
print("[MenuWindow] 選択されたメニュー: ", button_id)
func _hide_all_sub_layouts() -> void:
if party_layout: party_layout.hide()
if config_layout: config_layout.hide()
if save_layout: save_layout.hide()
if load_layout: load_layout.hide() # 【新設】
func _update_explanation(loc_id: String) -> void:
if menu_message_label:
menu_message_label.text = tr(loc_id)
# ==============================================================================
# メニュー画面全体のフルサイズ固定(全画面化)
# ==============================================================================
func set_menu_full_size() -> void:
if not main_menu_layout: return
self.custom_minimum_size = Vector2.ZERO
main_menu_layout.custom_minimum_size = Vector2.ZERO
var target_screen_size := Vector2(1280, 720)
self.set_anchors_preset(Control.PRESET_TOP_LEFT)
self.position = Vector2.ZERO
main_menu_layout.set_anchors_preset(Control.PRESET_TOP_LEFT)
main_menu_layout.position = Vector2.ZERO
self.size = target_screen_size
main_menu_layout.size = target_screen_size
self.custom_minimum_size = target_screen_size
main_menu_layout.custom_minimum_size = target_screen_size
# ==============================================================================
# 設定画面(Config)のコントロールロジック
# ==============================================================================
func _setup_config_ui() -> void:
if not language_button or not resolution_button or not config_back_button: return
language_button.clear()
language_button.add_item("English", 0)
language_button.add_item("日本語", 1)
language_button.select(0)
language_button.item_selected.connect(func(id): language_change_requested.emit(id))
resolution_button.clear()
resolution_button.add_item("1280x720", 0)
resolution_button.add_item("1920x1080", 1)
resolution_button.select(0)
resolution_button.item_selected.connect(func(id): resolution_change_requested.emit(id))
# ロード画面へ遷移するボタンの接続 【新設】
if load_button:
load_button.pressed.connect(_open_load_screen)
config_back_button.pressed.connect(_close_config_screen)
# ==============================================================================
# セーブ画面(Save)のコントロールロジック
# ==============================================================================
func _setup_save_ui() -> void:
if not next_slot_button or not save_back_button: return
next_slot_button.pressed.connect(_on_next_slot_pressed)
save_back_button.pressed.connect(_close_save_screen)
for i in range(3):
var btn = slot_buttons[i]
if btn:
btn.pressed.connect(func(): _on_slot_selected(i))
if save_confirm_dialog:
save_confirm_dialog.confirmed.connect(_execute_save)
save_confirm_dialog.canceled.connect(func(): slot_buttons[0].grab_focus())
if save_alert_dialog:
save_alert_dialog.confirmed.connect(_back_to_main_menu_after_save)
func _open_save_screen() -> void:
if not save_layout or not party_layout: return
party_layout.hide()
if config_layout: config_layout.hide()
save_layout.show()
_current_page_index = 0
_update_explanation("MENU_SAVE_MESSAGE_EXPLANATION")
_render_save_slots()
await get_tree().process_frame
if slot_buttons[0]:
slot_buttons[0].grab_focus()
func _render_save_slots() -> void:
var start_slot = (_current_page_index * 3) + 1
_render_generic_slots(start_slot, slot_buttons)
func _on_next_slot_pressed() -> void:
_current_page_index = (_current_page_index + 1) % 3
_render_save_slots()
await get_tree().process_frame
if slot_buttons[0]:
slot_buttons[0].grab_focus()
func _on_slot_selected(local_index: int) -> void:
_selected_slot_no = (_current_page_index * 3) + 1 + local_index
var header = SaveManager.get_slot_header(_selected_slot_no)
if not save_confirm_dialog: return
if not header["exists"]:
save_confirm_dialog.dialog_text = tr("MENU_SAVE_CONFIRM_DIALOG_NEW_DATA") % _selected_slot_no
else:
save_confirm_dialog.dialog_text = tr("MENU_SAVE_CONFIRM_DIALOG_OVERWRITE") % _selected_slot_no
save_confirm_dialog.popup_centered()
var cancel_btn = save_confirm_dialog.get_cancel_button()
if cancel_btn:
cancel_btn.grab_focus()
func _execute_save() -> void:
var success = SaveManager.save_game(_selected_slot_no)
if not save_alert_dialog: return
if success:
save_alert_dialog.dialog_text = tr("MENU_SAVE_ALERT_DIALOG_SUCCESS") % _selected_slot_no
else:
save_alert_dialog.dialog_text = tr("MENU_SAVE_ALERT_DIALOG_FAILURE") % _selected_slot_no
save_alert_dialog.popup_centered()
# ==============================================================================
# 【新設】ロード画面(Load)のコントロールロジック
# ==============================================================================
func _setup_load_ui() -> void:
if not load_next_slot_button or not load_back_button: return
load_next_slot_button.pressed.connect(_on_load_next_slot_pressed)
load_back_button.pressed.connect(_close_load_screen)
for i in range(3):
var btn = load_slot_buttons[i]
if btn:
btn.pressed.connect(func(): _on_load_slot_selected(i))
if load_confirm_dialog:
load_confirm_dialog.confirmed.connect(_execute_load)
load_confirm_dialog.canceled.connect(func(): load_slot_buttons[0].grab_focus()) # キャンセル時はスロットにフォーカス
if load_alert_dialog:
load_alert_dialog.confirmed.connect(_on_load_alert_confirmed)
func _render_load_slots() -> void:
var start_slot = (_load_current_page_index * 3) + 1
_render_generic_slots(start_slot, load_slot_buttons)
func _on_load_next_slot_pressed() -> void:
_load_current_page_index = (_load_current_page_index + 1) % 3
_render_load_slots()
await get_tree().process_frame
if load_slot_buttons[0]:
load_slot_buttons[0].grab_focus()
func _on_load_slot_selected(local_index: int) -> void:
_selected_load_slot_no = (_load_current_page_index * 3) + 1 + local_index
if not load_confirm_dialog: return
# 仕様:「データNo.%dをロードしますか?」の確認文を表示
load_confirm_dialog.dialog_text = tr("MENU_LOAD_CONFIRM_DIALOG_QUESTION") % _selected_load_slot_no
load_confirm_dialog.popup_centered()
# 仕様:デフォルトフォーカスを「いいえ (Cancel)」にする
var cancel_btn = load_confirm_dialog.get_cancel_button()
if cancel_btn:
cancel_btn.grab_focus()
func _execute_load() -> void:
# ロード処理を実行(この中で change_scene が走る)
_load_success_flag = LoadManager.load_game(_selected_load_slot_no)
if _load_success_flag:
# 【修正】ロード成功時はシーンが切り替わるため、この瞬間にダイアログを出してはいけない。
# 何もせず、LoadManager 側のシーン切り替え待機に処理を委ねる。
print("[MenuWindow] ロード成功。シーン切り替えを待機します。")
else:
# ロード失敗時のみ、シーンは切り替わらないので安全にエラーダイアログを出せる
if load_alert_dialog:
load_alert_dialog.dialog_text = tr("MENU_LOAD_ALERT_DIALOG_FAILURE") % _selected_load_slot_no
load_alert_dialog.popup_centered()
func _on_load_alert_confirmed() -> void:
# 成功時はここを通らなくなったため、ここは「失敗してアラートを閉じたとき」の処理のみにする
await get_tree().process_frame
if load_slot_buttons[0]:
load_slot_buttons[0].grab_focus()
# ==============================================================================
# セーブ/ロード共通のスロットレンダリング共通関数
# ==============================================================================
func _render_generic_slots(start_slot: int, target_buttons: Array) -> void:
for i in range(3):
var slot_no = start_slot + i
var btn: Button = target_buttons[i]
if not btn: continue
var header = SaveManager.get_slot_header(slot_no)
var parent_node = btn.get_parent()
var lbl_no: Label = null
if parent_node:
# セーブとロードでノード名が異なるため部分一致検索
lbl_no = parent_node.find_child("*SlotNoLabel", true, false)
var rect_face: TextureRect = btn.find_child("SlotAppearance", true, false)
var lbl_time: Label = btn.find_child("SlotPlayTimeLabel", true, false)
var lbl_name: Label = btn.find_child("SlotNameLabel", true, false)
var lbl_lv: Label = btn.find_child("SlotLvLabel", true, false)
var lbl_class: Label = btn.find_child("SlotClassLabel", true, false)
if lbl_no: lbl_no.text = "No.%d" % slot_no
if not header["exists"]:
if lbl_time: lbl_time.text = "--:--:--"
if lbl_name: lbl_name.text = tr("MENU_SAVE_SLOT_EMPTY")
if lbl_lv: lbl_lv.text = ""
if lbl_class: lbl_class.text = ""
if rect_face: rect_face.texture = null
else:
if lbl_time: lbl_time.text = header.get("play_time", "00:00:00")
var party_data = header.get("party", [])
if party_data.size() > 0:
var chara = party_data[0]
if lbl_name: lbl_name.text = chara["name"]
if lbl_lv: lbl_lv.text = "Lv.%d" % chara["level"]
if lbl_class: lbl_class.text = tr(chara["class"])
if rect_face and chara["face_image_path"] and FileAccess.file_exists(chara["face_image_path"]):
rect_face.texture = load(chara["face_image_path"])
elif rect_face:
rect_face.texture = null
else:
if lbl_name: lbl_name.text = tr("MENU_SAVE_SLOT_NO_PARTY")
if lbl_lv: lbl_lv.text = ""
if lbl_class: lbl_class.text = ""
if rect_face: rect_face.texture = null
# ==============================================================================
# サイズ調整・パーティ表示・その他(修正・追加版)
# ==============================================================================
func set_config_layout_size(resolution_index: int) -> void:
if not config_layout: return
var target_config_size := Vector2(320, 180)
match resolution_index:
0: target_config_size = Vector2(320, 180)
1: target_config_size = Vector2(480, 270)
config_layout.custom_minimum_size = target_config_size
config_layout.size = target_config_size
func set_main_layouts_size() -> void:
if not l_main_menu_layout or not r_main_menu_layout: return
var left_width := 1120.0
var right_width := 128.0
l_main_menu_layout.custom_minimum_size = Vector2(left_width, 0)
l_main_menu_layout.size.x = left_width
r_main_menu_layout.custom_minimum_size = Vector2(right_width, 0)
r_main_menu_layout.size.x = right_width
func _update_party_display() -> void:
for i in range(6):
var member_panel = member_nodes[i]
if not member_panel: continue
member_panel.show()
var data = PartyManager.slots[i]
var label_name: Label = member_panel.find_child("Name", true, false)
var label_lv: Label = member_panel.find_child("Lv", true, false)
var rect_appearance: TextureRect = member_panel.find_child("Appearance", true, false)
var label_class: Label = member_panel.find_child("Class", true, false)
var label_hp: Label = member_panel.find_child("HPLabel", true, false)
var bar_hp: ProgressBar = member_panel.find_child("HPBar", true, false)
var label_mp: Label = member_panel.find_child("MPLabel", true, false)
var bar_mp: ProgressBar = member_panel.find_child("MPBar", true, false)
if data == null:
if label_name: label_name.text = ""
if label_lv: label_lv.text = ""
if label_class: label_class.text = ""
if label_hp: label_hp.text = ""
if label_mp: label_mp.text = ""
if rect_appearance: rect_appearance.texture = null
if bar_hp: bar_hp.hide()
if bar_mp: bar_mp.hide()
else:
if bar_hp: bar_hp.show()
if bar_mp: bar_mp.show()
if label_name: label_name.text = data.chara_name
if label_lv: label_lv.text = "Lv%d" % data.level
if label_class: label_class.text = data.get_display_class()
if label_hp: label_hp.text = "HP %d/%d" % [data.current_hp, data.max_hp]
if label_mp: label_mp.text = "MP %d/%d" % [data.current_mp, data.max_mp]
if rect_appearance:
if data.face_image_path and FileAccess.file_exists(data.face_image_path):
rect_appearance.texture = load(data.face_image_path)
else:
rect_appearance.texture = null
rect_appearance.custom_minimum_size = Vector2(160, 160)
rect_appearance.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
if bar_hp:
bar_hp.min_value = 0
bar_hp.max_value = data.max_hp
bar_hp.value = data.current_hp
if bar_mp:
bar_mp.min_value = 0
bar_mp.max_value = data.max_mp
bar_mp.value = data.current_mp
func setup_party_layout_sizes() -> void:
if party_layout and front_row_party_member and back_row_party_member:
party_layout.custom_minimum_size = Vector2(960, 0)
party_layout.size.x = 960
front_row_party_member.custom_minimum_size = Vector2(960, 0)
back_row_party_member.custom_minimum_size = Vector2(960, 0)
for member_panel in member_nodes:
if not member_panel: continue
member_panel.custom_minimum_size = Vector2(320, 240)
member_panel.size = Vector2(320, 240)
var member_hbox = member_panel.get_node_or_null("MemberHBox1")
if not member_hbox: member_hbox = member_panel.find_child("MemberHBox*", true, false)
if member_hbox:
var l_part = member_hbox.get_node_or_null("LPartyMember1")
if not l_part: l_part = member_hbox.find_child("LPartyMember*", true, false)
if l_part:
l_part.custom_minimum_size = Vector2(160, 0)
l_part.size.x = 160
var r_part = member_hbox.get_node_or_null("RPartyMember1")
if not r_part: r_part = member_hbox.find_child("RPartyMember*", true, false)
if r_part:
r_part.custom_minimum_size = Vector2(160, 0)
r_part.size.x = 160
# セーブレイアウト側の固定
if save_layout:
_apply_layout_fixing_sizes(save_layout, slot_buttons)
# ロードレイアウト側の固定
if load_layout:
_apply_layout_fixing_sizes(load_layout, load_slot_buttons)
## 前回のレイアウト崩れ・名前の長さによるズレ防止を共通適用するプライベート関数
func _apply_layout_fixing_sizes(target_layout: Control, target_buttons: Array) -> void:
target_layout.custom_minimum_size = Vector2(420, 0)
target_layout.size.x = 420
for btn in target_buttons:
if btn is Button:
var current_height = btn.size.y if btn.size.y > 0 else 120.0
btn.custom_minimum_size = Vector2(420, current_height)
btn.size = Vector2(420, current_height)
var slot_left = btn.find_child("SlotLeft", true, false)
if slot_left and slot_left is Control:
slot_left.custom_minimum_size = Vector2(160, 0)
slot_left.size.x = 160
var slot_right = btn.find_child("SlotRight", true, false)
if slot_right and slot_right is Control:
slot_right.custom_minimum_size = Vector2(260, 0)
slot_right.size.x = 260
print("[MenuWindow] レイアウトの横幅を固定長にロックしました: ", target_layout.name)
メニュー画面用スクリプト (menu_window.gd) を上記コードに修正します。これでゲームデータのロードができるようになりました。
コメント