Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
This commit is contained in:
+254
-28
@@ -1,6 +1,19 @@
|
||||
extends Control
|
||||
|
||||
const UIAudioFeedbackScript := preload("res://scripts/ui/ui_audio_feedback.gd")
|
||||
const UIAudioSettingsDialogScript := preload("res://scripts/ui/ui_audio_settings_dialog.gd")
|
||||
const UIAudioOwnerShutdownScript := preload("res://scripts/ui/ui_audio_owner_shutdown.gd")
|
||||
const PairedBGMScript := preload("res://scripts/ui/paired_bgm.gd")
|
||||
var _paired_bgm: Variant
|
||||
signal ui_audio_window_close_drained(report: Dictionary)
|
||||
var audio_settings_path := "user://settings/audio.cfg"
|
||||
var _audio_feedback: Variant
|
||||
var _audio_settings_dialog: Variant
|
||||
var _window_close_in_progress := false
|
||||
|
||||
const RankDialogScript := preload("res://scripts/ui/world_tree_rank_dialog.gd")
|
||||
const EventLogDialogScript := preload("res://scripts/ui/event_log_dialog.gd")
|
||||
const ManualSaveSlotsDialogScript := preload("res://scripts/ui/manual_save_slots_dialog.gd")
|
||||
|
||||
const OpeningGuideScript := preload("res://scripts/ui/inn_opening_guide.gd")
|
||||
const OperatingLedgerPanelScript := preload("res://scripts/ui/operating_ledger_panel.gd")
|
||||
@@ -113,11 +126,13 @@ const STAFF_SKILL_NAMES := {
|
||||
|
||||
@export var visual_theme: VisualThemeProfile = DefaultVisualTheme
|
||||
var manual_save_path := MANUAL_SAVE_PATH
|
||||
var manual_save_slots_dialog: ConfirmationDialog
|
||||
|
||||
var time_label: Label
|
||||
var phase_label: Label
|
||||
var rank_button: Button
|
||||
var rank_dialog: ConfirmationDialog
|
||||
var event_log_dialog: AcceptDialog
|
||||
var weather_label: Label
|
||||
var guest_count_label: Button
|
||||
var dimension_migration_dialog: ConfirmationDialog
|
||||
@@ -205,6 +220,9 @@ func _ready() -> void:
|
||||
if not bool(current_operations.get(&"enabled", false)):
|
||||
_operations_start_report = _runtime.call(&"enable_production_operations")
|
||||
_synchronize_ui_command_state()
|
||||
_setup_ui_audio()
|
||||
UIAudioOwnerShutdownScript.register_owner(get_tree(), self)
|
||||
get_tree().root.close_requested.connect(_on_window_close_requested)
|
||||
_build_interface()
|
||||
_connect_signals()
|
||||
_populate_build_palette()
|
||||
@@ -225,6 +243,43 @@ func _ready() -> void:
|
||||
_focus_initial_view.call_deferred()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var tree := get_tree()
|
||||
if tree != null:
|
||||
if tree.root.close_requested.is_connected(_on_window_close_requested):
|
||||
tree.root.close_requested.disconnect(_on_window_close_requested)
|
||||
UIAudioOwnerShutdownScript.unregister_owner(tree, self)
|
||||
|
||||
|
||||
func _on_window_close_requested() -> void:
|
||||
# The shared coordinator drains every registered Main before one quit.
|
||||
# Main stays alive while its audio child is freed and the backend releases.
|
||||
var tree := get_tree()
|
||||
UIAudioOwnerShutdownScript.close_owners(tree)
|
||||
|
||||
|
||||
func _report_audio_close_drained(report: Dictionary) -> void:
|
||||
ui_audio_window_close_drained.emit(report)
|
||||
|
||||
|
||||
func _prepare_owned_audio_close() -> Array[Dictionary]:
|
||||
_window_close_in_progress = true
|
||||
set_process(false)
|
||||
set_process_input(false)
|
||||
set_process_unhandled_input(false)
|
||||
set_process_unhandled_key_input(false)
|
||||
hide()
|
||||
var audio: Node = _audio_feedback as Node
|
||||
_audio_feedback = null
|
||||
var targets := UIAudioOwnerShutdownScript.capture_and_release(audio)
|
||||
if is_instance_valid(_paired_bgm):
|
||||
targets.append_array(_paired_bgm.capture_owned_targets())
|
||||
_paired_bgm.prepare_owner_shutdown()
|
||||
_paired_bgm.queue_free()
|
||||
_paired_bgm = null
|
||||
return targets
|
||||
|
||||
|
||||
func _focus_initial_view() -> void:
|
||||
await get_tree().process_frame
|
||||
world_preview.focus_operating_area()
|
||||
@@ -281,6 +336,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
_:
|
||||
handled = false
|
||||
if handled:
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.request_click()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
@@ -377,6 +434,9 @@ func _create_top_bar() -> Control:
|
||||
title_box.add_child(rank_button)
|
||||
rank_dialog=RankDialogScript.new()
|
||||
rank_dialog.configure(_runtime)
|
||||
rank_dialog.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||||
_audio_feedback.bind_button(rank_dialog.get_ok_button())
|
||||
_audio_feedback.bind_button(rank_dialog.get_cancel_button())
|
||||
rank_dialog.status_requested.connect(func(message: String, success: bool) -> void:
|
||||
_set_status(message,success)
|
||||
_refresh_dashboard()
|
||||
@@ -561,6 +621,7 @@ func _create_side_panel() -> Control:
|
||||
column.add_child(candidate_heading_row)
|
||||
staff_management_panel = StaffManagementPanelScript.new()
|
||||
staff_management_panel.configure(_runtime, _apply_button_style)
|
||||
staff_management_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||||
staff_management_panel.status_requested.connect(_on_staff_management_status)
|
||||
staff_management_panel.visible = false
|
||||
column.add_child(staff_management_panel)
|
||||
@@ -624,15 +685,19 @@ func _create_side_panel() -> Control:
|
||||
operating_ledger_panel = OperatingLedgerPanelScript.new()
|
||||
root_column.add_child(operating_ledger_panel)
|
||||
operating_ledger_panel.setup(_runtime, GameClock, _apply_button_style, GameState.active_layer)
|
||||
operating_ledger_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||||
operating_ledger_panel.status_requested.connect(_on_staff_management_status)
|
||||
operating_ledger_panel.visible = false
|
||||
supply_management_panel = SupplyManagementPanelScript.new()
|
||||
supply_management_panel.configure(_runtime, _apply_button_style)
|
||||
supply_management_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||||
supply_management_panel.status_requested.connect(_on_staff_management_status)
|
||||
supply_management_panel.action_requested.connect(_activate_management_mode)
|
||||
supply_management_panel.visible = false
|
||||
root_column.add_child(supply_management_panel)
|
||||
business_management_panel = BusinessManagementPanelScript.new()
|
||||
business_management_panel.configure(_runtime, _apply_button_style)
|
||||
business_management_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||||
business_management_panel.status_requested.connect(_on_staff_management_status)
|
||||
business_management_panel.action_requested.connect(_activate_management_mode)
|
||||
business_management_panel.visible = false
|
||||
@@ -676,7 +741,7 @@ func _create_bottom_bar() -> Control:
|
||||
pan_button = _button("画面移動", _toggle_pan_mode, 90)
|
||||
pan_button.name = "PanModeButton"
|
||||
pan_button.toggle_mode = true
|
||||
pan_button.tooltip_text = "左ドラッグで画面を移動。もう一度押すか Esc で解除。ホイールで拡大縮小、Homeで全体表示。"
|
||||
pan_button.tooltip_text = "左ドラッグとホイールで画面を上下移動。Ctrl+ホイールで拡大縮小。もう一度押すか Esc で解除、Homeで全体表示。"
|
||||
row.add_child(pan_button)
|
||||
var focus_button := _button("営業階", func() -> void: world_preview.focus_operating_area(), 64)
|
||||
focus_button.name = "FocusOperatingAreaButton"
|
||||
@@ -689,9 +754,20 @@ func _create_bottom_bar() -> Control:
|
||||
management_toggle_button.name = "ManagementToggleButton"
|
||||
management_toggle_button.toggle_mode = true
|
||||
row.add_child(management_toggle_button)
|
||||
event_log_dialog = EventLogDialogScript.new()
|
||||
event_log_dialog.configure(_runtime)
|
||||
add_child(event_log_dialog)
|
||||
var event_log_button := _button("記録", event_log_dialog.show_events, 62)
|
||||
event_log_button.name = "EventLogButton"
|
||||
event_log_button.tooltip_text = "最近のイベントを確認"
|
||||
row.add_child(event_log_button)
|
||||
var save_button := _button("保存", _save_manual, 62)
|
||||
save_button.name = "ManualSaveButton"
|
||||
_apply_button_icon(save_button, visual_theme.control_icon(&"save") if visual_theme != null else null, 16)
|
||||
row.add_child(save_button)
|
||||
var audio_button := _button("音設定", _show_audio_settings, 62)
|
||||
audio_button.name = "AudioSettingsButton"
|
||||
row.add_child(audio_button)
|
||||
var load_menu := MenuButton.new()
|
||||
load_menu.name = "SaveLoadMenu"
|
||||
load_menu.text = "読込 ▾"
|
||||
@@ -700,9 +776,11 @@ func _create_bottom_bar() -> Control:
|
||||
_apply_button_style(load_menu)
|
||||
load_menu.get_popup().add_item("手動セーブを読み込む",0)
|
||||
load_menu.get_popup().add_item("旧寸法を修正して読み込む",1)
|
||||
load_menu.get_popup().add_item("保存スロットを選んで読込",2)
|
||||
load_menu.get_popup().id_pressed.connect(func(id: int):
|
||||
if id == 0: _load_manual()
|
||||
elif id == 1: _preview_dimension_migration())
|
||||
elif id == 1: _preview_dimension_migration()
|
||||
elif id == 2: _show_manual_load_slots())
|
||||
row.add_child(load_menu)
|
||||
|
||||
palette_scroll = ScrollContainer.new()
|
||||
@@ -865,11 +943,14 @@ func _connect_signals() -> void:
|
||||
GameClock.phase_changed.connect(_on_phase_changed)
|
||||
GameState.resource_changed.connect(_on_resource_changed)
|
||||
world_preview.facility_selected.connect(_on_facility_selected)
|
||||
world_preview.facility_selected.connect(_on_audio_world_selection)
|
||||
world_preview.staff_assignment_dropped.connect(func(data: Dictionary, facility_id: StringName) -> void:
|
||||
staff_management_panel.assign_from_drop(data, facility_id)
|
||||
)
|
||||
world_preview.legacy_stair_selected.connect(_on_legacy_stair_selected)
|
||||
world_preview.legacy_stair_selected.connect(_on_audio_world_selection)
|
||||
world_preview.construction_selected.connect(_on_construction_selected)
|
||||
world_preview.construction_selected.connect(_on_audio_world_selection)
|
||||
world_preview.cell_clicked.connect(_on_map_cell_clicked)
|
||||
world_preview.build_preview_changed.connect(_on_build_preview_changed)
|
||||
world_preview.excavation_preview_changed.connect(_on_excavation_preview_changed)
|
||||
@@ -1085,9 +1166,7 @@ func _hire_staff_candidate(candidate_id: StringName) -> void:
|
||||
var idempotency_key := StringName(
|
||||
"ui.hire.%s.%08d" % [String(candidate_id), sequence]
|
||||
)
|
||||
var result: Variant = _runtime.call(
|
||||
&"submit_command",
|
||||
&"hire_staff_candidate",
|
||||
var result: Variant = _submit_ui_command(&"hire_staff_candidate",
|
||||
{&"candidate_id": candidate_id},
|
||||
&"player",
|
||||
candidate_id,
|
||||
@@ -1215,7 +1294,7 @@ func _toggle_excavation_mode() -> void:
|
||||
|
||||
func _toggle_pan_mode() -> void:
|
||||
_set_pan_mode(not world_preview.pan_mode)
|
||||
_set_status("画面移動中:左ドラッグで移動、ホイールで拡大縮小。Escで解除。" if world_preview.pan_mode else "画面移動を解除しました。施設をクリックすると情報を表示します。", true, true)
|
||||
_set_status("画面移動中:左ドラッグで移動、ホイールで上下移動、Ctrl+ホイールで拡大縮小。Escで解除。" if world_preview.pan_mode else "画面移動を解除しました。施設をクリックすると情報を表示します。", true, true)
|
||||
|
||||
|
||||
func _set_pan_mode(enabled: bool) -> void:
|
||||
@@ -1273,6 +1352,8 @@ func _rotate_build_preview() -> void:
|
||||
|
||||
|
||||
func _on_map_cell_clicked(cell: Vector3i) -> void:
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.request_click()
|
||||
if _excavation_mode:
|
||||
_start_excavation(cell)
|
||||
return
|
||||
@@ -1287,6 +1368,8 @@ func _on_map_cell_clicked(cell: Vector3i) -> void:
|
||||
return
|
||||
var preview := world_preview.get_build_preview_report(cell)
|
||||
if not bool(preview.get(&"can_build", false)):
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.reject_local(&"BUILD_PREVIEW_REJECTED")
|
||||
_set_status(_preview_failure_message(preview), false)
|
||||
return
|
||||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||||
@@ -1294,9 +1377,7 @@ func _on_map_cell_clicked(cell: Vector3i) -> void:
|
||||
return
|
||||
var idempotency_key := StringName("ui.build.%08d" % _build_sequence)
|
||||
_build_sequence += 1
|
||||
var result: Variant = _runtime.call(
|
||||
&"submit_command",
|
||||
&"build_facility",
|
||||
var result: Variant = _submit_ui_command(&"build_facility",
|
||||
{&"definition_id": _selected_definition_id, &"origin": cell, &"rotation": _build_rotation},
|
||||
&"player",
|
||||
&"",
|
||||
@@ -1324,7 +1405,9 @@ func _on_build_preview_changed(report: Dictionary) -> void:
|
||||
return
|
||||
if bool(report.get(&"can_build", false)):
|
||||
var cell: Vector3i = world_preview.get_hovered_cell()
|
||||
_set_status("配置可能 (%d, %d)・%d° — クリックで建設" % [cell.x, cell.y, _build_rotation], true, true)
|
||||
var connection: Dictionary = report.get(&"connection", {})
|
||||
var warning := " · 入口への経路未接続" if connection.get(&"status", &"") == &"DISCONNECTED" else " · 経路確認不可" if connection.get(&"status", &"") == &"UNAVAILABLE" else ""
|
||||
_set_status("配置可能 (%d, %d)・%d°%s — クリックで建設" % [cell.x, cell.y, _build_rotation, warning], true, true)
|
||||
else:
|
||||
_set_status(_preview_failure_message(report), false, true)
|
||||
|
||||
@@ -1369,9 +1452,7 @@ func _start_excavation(cell: Vector3i) -> void:
|
||||
_set_status("このマスの掘削指示はすでに受理されています。", false)
|
||||
return
|
||||
var idempotency_key := _excavation_idempotency_key(cell)
|
||||
var result: Variant = _runtime.call(
|
||||
&"submit_command",
|
||||
&"start_excavation",
|
||||
var result: Variant = _submit_ui_command(&"start_excavation",
|
||||
{&"cell": Vector3i(cell.x, cell.y, world_preview.layer_mode)},
|
||||
&"player",
|
||||
&"",
|
||||
@@ -1436,6 +1517,8 @@ func _on_construction_selected(job_id: StringName) -> void:
|
||||
|
||||
|
||||
func _refresh_dashboard() -> void:
|
||||
if event_log_dialog != null and event_log_dialog.visible:
|
||||
event_log_dialog.refresh()
|
||||
if rank_button != null and _service(&"progression_service") != null:
|
||||
rank_button.text="世界樹ランク %d" % _service(&"progression_service").current_rank
|
||||
if rank_dialog != null and rank_dialog.visible: rank_dialog.refresh()
|
||||
@@ -1544,6 +1627,8 @@ func _refresh_foreground_event(event_service: Variant) -> void:
|
||||
foreground_event_panel.visible = false
|
||||
return
|
||||
foreground_event_panel.visible = true
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.observe_foreground(foreground)
|
||||
_set_ledger_panel_open(false)
|
||||
_set_supply_panel_open(false)
|
||||
_set_management_panel_open(true)
|
||||
@@ -1611,9 +1696,7 @@ func _submit_foreground_event_choice(
|
||||
var idempotency_key := StringName(
|
||||
"ui.event.choice.%s.%s" % [String(choice_instance_id), String(choice_id)]
|
||||
)
|
||||
var result: Variant = _runtime.call(
|
||||
&"submit_command",
|
||||
&"submit_event_choice",
|
||||
var result: Variant = _submit_ui_command(&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": event_instance_id,
|
||||
&"choice_instance_id": choice_instance_id,
|
||||
@@ -1774,7 +1857,7 @@ func _on_related_facility_selected(index: int) -> void:
|
||||
if _runtime == null or _selected_entity_id.is_empty():
|
||||
return
|
||||
var related_id := StringName(related_facility_picker.get_item_metadata(index))
|
||||
var result: Variant = _runtime.call(&"submit_command", &"set_facility_conditions", {&"facility_id": _selected_entity_id, &"conditions": {&"related_facility_id": related_id}})
|
||||
var result: Variant = _submit_ui_command(&"set_facility_conditions", {&"facility_id": _selected_entity_id, &"conditions": {&"related_facility_id": related_id}})
|
||||
if result.is_success() and not result.details.has(&"instance_id"):
|
||||
_pending_related_commands[int(result.command_id)] = true
|
||||
_set_status("関連施設の変更を受け付けました。" if result.is_success() else result.message, result.is_success(), true)
|
||||
@@ -1908,7 +1991,7 @@ func _on_elevator_operation_pressed() -> void:
|
||||
var key := StringName("ui.elevator.%08d" % _elevator_sequence)
|
||||
_elevator_sequence += 1
|
||||
var open_value := not bool(shaft.get(&"operator_open", true))
|
||||
var result: Variant = _runtime.call(&"submit_command", &"set_elevator_operation", {&"shaft_id": shaft_id, &"operator_open": open_value}, &"player", &"", 50, -1, key)
|
||||
var result: Variant = _submit_ui_command(&"set_elevator_operation", {&"shaft_id": shaft_id, &"operator_open": open_value}, &"player", &"", 50, -1, key)
|
||||
if result == null or not result.is_success():
|
||||
_set_status("昇降機の指示を反映できませんでした。", false)
|
||||
return
|
||||
@@ -2118,21 +2201,55 @@ func _command_failure_message(result: Variant) -> String:
|
||||
|
||||
|
||||
func _save_manual() -> void:
|
||||
_save_manual_at_path(manual_save_path, "手動保存")
|
||||
|
||||
|
||||
func _save_manual_at_path(save_path: String, display_name: String) -> bool:
|
||||
if _runtime == null or not _runtime.has_method(&"save_to_path"):
|
||||
_set_status("保存サービスへ接続できません。", false)
|
||||
return
|
||||
var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(manual_save_path).get_base_dir())
|
||||
return false
|
||||
var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(save_path).get_base_dir())
|
||||
if directory_error != OK and directory_error != ERR_ALREADY_EXISTS:
|
||||
_set_status("保存フォルダを作成できませんでした。", false)
|
||||
return
|
||||
var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存", &"interface_selection": _capture_interface_selection()})
|
||||
return false
|
||||
var metadata := {&"display_name": display_name, &"interface_selection": _capture_interface_selection()}
|
||||
var thumbnail := _capture_manual_thumbnail_png()
|
||||
if not thumbnail.is_empty(): metadata[&"thumbnail_png"] = thumbnail
|
||||
var result: Variant = _runtime.call(&"save_to_path", save_path, metadata)
|
||||
if result is Dictionary and bool(result.get(&"ok", false)):
|
||||
manual_save_path = save_path
|
||||
_set_status("手動セーブを保存しました。", true)
|
||||
return true
|
||||
if result is Dictionary and StringName(result.get(&"code", &"")) == &"FORMAT_MIGRATION_REQUIRED":
|
||||
_set_status("元の保存を残すため、別のスロットへ保存してください。", false)
|
||||
_ensure_manual_save_slots().show_new_save(save_path)
|
||||
else:
|
||||
_set_status("保存に失敗しました: %s" % _result_message(result), false)
|
||||
return false
|
||||
|
||||
|
||||
func _capture_manual_thumbnail_png() -> PackedByteArray:
|
||||
# Capture the existing rendered game; no simulation update or new art source.
|
||||
# Dummy/headless rendering retains the existing SaveService fallback.
|
||||
if DisplayServer.get_name() == "headless": return PackedByteArray()
|
||||
var viewport := get_viewport()
|
||||
if viewport == null: return PackedByteArray()
|
||||
var texture := viewport.get_texture()
|
||||
if texture == null: return PackedByteArray()
|
||||
var image := texture.get_image()
|
||||
if image == null or image.is_empty(): return PackedByteArray()
|
||||
var scale := minf(320.0 / float(image.get_width()), 180.0 / float(image.get_height()))
|
||||
var width := maxi(1, roundi(float(image.get_width()) * scale))
|
||||
var height := maxi(1, roundi(float(image.get_height()) * scale))
|
||||
image.resize(width, height, Image.INTERPOLATE_LANCZOS)
|
||||
return image.save_png_to_buffer()
|
||||
|
||||
|
||||
func _load_manual(strict: bool = false) -> void:
|
||||
_load_manual_at_path(manual_save_path, strict)
|
||||
|
||||
|
||||
func _load_manual_at_path(save_path: String, strict: bool = false) -> void:
|
||||
if _runtime == null or (
|
||||
not _runtime.has_method(&"load_with_recovery")
|
||||
and not _runtime.has_method(&"load_from_path")
|
||||
@@ -2140,11 +2257,12 @@ func _load_manual(strict: bool = false) -> void:
|
||||
_set_status("読込サービスへ接続できません。", false)
|
||||
return
|
||||
var result: Variant = (
|
||||
_runtime.call(&"load_with_recovery", manual_save_path)
|
||||
_runtime.call(&"load_with_recovery", save_path)
|
||||
if not strict and _runtime.has_method(&"load_with_recovery")
|
||||
else _runtime.call(&"load_from_path", manual_save_path)
|
||||
else _runtime.call(&"load_from_path", save_path)
|
||||
)
|
||||
if result is Dictionary and bool(result.get(&"ok", false)):
|
||||
manual_save_path = save_path
|
||||
# Real Runtime already emitted its common completion notification.
|
||||
if not _runtime.has_signal(&"save_operation_completed"):
|
||||
_apply_loaded_interface(result)
|
||||
@@ -2153,8 +2271,37 @@ func _load_manual(strict: bool = false) -> void:
|
||||
_set_status("読み込みに失敗しました: %s" % _result_message(result), false)
|
||||
|
||||
|
||||
func _ensure_manual_save_slots() -> Variant:
|
||||
if manual_save_slots_dialog == null:
|
||||
manual_save_slots_dialog = ManualSaveSlotsDialogScript.new()
|
||||
manual_save_slots_dialog.connect(&"new_slot_requested", _save_new_manual_slot)
|
||||
manual_save_slots_dialog.connect(&"load_slot_requested", _load_selected_manual_slot)
|
||||
add_child(manual_save_slots_dialog)
|
||||
return manual_save_slots_dialog
|
||||
|
||||
|
||||
func _save_new_manual_slot(path: String, display_name: String) -> void:
|
||||
if not ManualSaveSlotsDialogScript.is_fresh_slot(path) or path.get_base_dir() != manual_save_path.get_base_dir():
|
||||
_set_status("新しい保存先を用意できませんでした。元の保存は変更していません。", false)
|
||||
return
|
||||
_save_manual_at_path(path, display_name)
|
||||
|
||||
|
||||
func _show_manual_load_slots() -> void:
|
||||
_ensure_manual_save_slots().show_load_slots(manual_save_path)
|
||||
|
||||
|
||||
func _load_selected_manual_slot(path: String) -> void:
|
||||
if path.get_base_dir() != manual_save_path.get_base_dir() or not ManualSaveSlotsDialogScript.is_owned_slot_name(path.get_file()):
|
||||
_set_status("この保存先を読み込めません。", false)
|
||||
return
|
||||
_load_manual_at_path(path)
|
||||
|
||||
func _capture_interface_selection() -> Dictionary:
|
||||
var selection := {&"version": 1, &"kind": String(_selected_entity_kind), &"id": String(_selected_entity_id), &"staff_open": _staff_management_panel_open, &"guest_open": false}
|
||||
selection[&"product_open"] = _supply_panel_open
|
||||
if supply_management_panel != null:
|
||||
selection[&"product_tab"] = String(supply_management_panel.selected_tab)
|
||||
if staff_management_panel != null:
|
||||
selection[&"staff_id"] = String(staff_management_panel.get("selected_staff_id"))
|
||||
if guest_observer != null:
|
||||
@@ -2181,6 +2328,8 @@ func _on_save_operation_completed(result: Dictionary) -> void:
|
||||
|
||||
|
||||
func _apply_loaded_interface(result: Dictionary) -> void:
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.begin_loaded_epoch(_current_audio_foreground())
|
||||
_pending_build_commands.clear()
|
||||
_pending_excavation_commands.clear()
|
||||
_pending_hire_commands.clear()
|
||||
@@ -2200,6 +2349,7 @@ func _apply_loaded_interface(result: Dictionary) -> void:
|
||||
_restore_interface_selection(result.get(&"manifest", {}))
|
||||
_restore_actor_interface_selection(result.get(&"manifest", {}))
|
||||
_restore_defense_interface_selection(result.get(&"manifest", {}))
|
||||
_restore_product_interface_selection(result.get(&"manifest", {}))
|
||||
_refresh_dashboard()
|
||||
_update_pause_and_speed()
|
||||
|
||||
@@ -2244,13 +2394,12 @@ func _restore_actor_interface_selection(manifest: Variant) -> void:
|
||||
# then restore each optional inspector independently of the map selection.
|
||||
_set_staff_management_panel_open(false)
|
||||
if staff_management_panel != null:
|
||||
staff_management_panel.call("select_staff", &"")
|
||||
staff_management_panel.call("clear_selection")
|
||||
if guest_observer != null:
|
||||
guest_observer.hide()
|
||||
var old_panel := guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel
|
||||
if old_panel != null:
|
||||
old_panel.selected_guest_id = &""
|
||||
old_panel.refresh()
|
||||
old_panel.clear_selection()
|
||||
if not manifest is Dictionary:
|
||||
return
|
||||
var selection: Variant = manifest.get("interface_selection", {})
|
||||
@@ -2270,6 +2419,34 @@ func _restore_actor_interface_selection(manifest: Variant) -> void:
|
||||
_show_guest_observer()
|
||||
|
||||
|
||||
func _restore_product_interface_selection(manifest: Variant) -> void:
|
||||
# Additive optional presentation: old packages keep their existing behavior.
|
||||
# No purchase draft, quantity, policy, assignment or command is restored here.
|
||||
if supply_management_panel == null or not manifest is Dictionary:
|
||||
return
|
||||
var selection: Variant = manifest.get("interface_selection", {})
|
||||
if not selection is Dictionary:
|
||||
return
|
||||
var version: Variant = selection.get("version", 0)
|
||||
if not (version is int or version is float) or version != 1:
|
||||
return
|
||||
var open_value: Variant = selection.get("product_open", null)
|
||||
var tab_value: Variant = selection.get("product_tab", null)
|
||||
if not open_value is bool or not tab_value is String:
|
||||
return
|
||||
if tab_value not in ["purchase", "kitchen", "delivery"]:
|
||||
return
|
||||
# A captured main panel cannot be both staff and product. Ignore a
|
||||
# contradictory optional product claim without overriding restored staff.
|
||||
if open_value and selection.get("staff_open", false) is bool and selection.get("staff_open", false):
|
||||
return
|
||||
supply_management_panel.select_tab(StringName(tab_value))
|
||||
if open_value:
|
||||
_activate_management_mode(&"product")
|
||||
else:
|
||||
_set_supply_panel_open(false)
|
||||
|
||||
|
||||
func _result_message(result: Variant) -> String:
|
||||
if result is Dictionary:
|
||||
var message := String(result.get(&"message", result.get(&"code", "保存データがありません")))
|
||||
@@ -2434,6 +2611,8 @@ func _on_command_completed(result: Variant) -> void:
|
||||
|
||||
func _set_layer(value: int) -> void:
|
||||
var normalized := clampi(value, 0, 1)
|
||||
if is_instance_valid(_paired_bgm):
|
||||
_paired_bgm.set_layer(normalized)
|
||||
if world_preview != null:
|
||||
world_preview.layer_mode = normalized
|
||||
GameState.active_layer = normalized
|
||||
@@ -2448,6 +2627,8 @@ func _set_layer(value: int) -> void:
|
||||
if _interface_layer >= 0 and _interface_layer != normalized:
|
||||
LayerSkin.apply_tree(self, normalized)
|
||||
if rank_dialog != null: rank_dialog.apply_layer_skin(normalized)
|
||||
if event_log_dialog != null: event_log_dialog.apply_layer_skin(normalized)
|
||||
if manual_save_slots_dialog != null: manual_save_slots_dialog.apply_layer_skin(normalized)
|
||||
_interface_layer = normalized
|
||||
if foreground_event_label != null:
|
||||
foreground_event_label.add_theme_color_override("font_color", Color("ffb498") if normalized == 1 else Color("8f3f29"))
|
||||
@@ -2627,6 +2808,8 @@ func _apply_button_icon(button: Button, icon_texture: Texture2D, maximum_width:
|
||||
|
||||
|
||||
func _apply_button_style(button: Button) -> void:
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.bind_button(button)
|
||||
button.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff0c8")))
|
||||
button.add_theme_color_override("font_hover_color", Color("fff8e5"))
|
||||
button.add_theme_color_override("font_pressed_color", Color("fff8e5"))
|
||||
@@ -2818,6 +3001,7 @@ func _ensure_defense_manager() -> DefenseManagementPanel:
|
||||
_apply_button_style(defense_manager.get_ok_button())
|
||||
var panel := DefenseManagementPanelScript.new()
|
||||
panel.configure(_runtime, _apply_button_style)
|
||||
panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||||
panel.status_requested.connect(_on_staff_management_status)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.name = "DefenseManagementScroll"
|
||||
@@ -2841,7 +3025,7 @@ func _restore_defense_interface_selection(manifest: Variant) -> void:
|
||||
if defense_manager != null:
|
||||
defense_manager.hide()
|
||||
var existing := defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel
|
||||
if existing != null: existing.select_raid(&"")
|
||||
if existing != null: existing.clear_selection()
|
||||
if not manifest is Dictionary: return
|
||||
var selection: Variant = manifest.get("interface_selection", {})
|
||||
if not selection is Dictionary or selection.get("version", 0) != 1: return
|
||||
@@ -2852,3 +3036,45 @@ func _restore_defense_interface_selection(manifest: Variant) -> void:
|
||||
_ensure_defense_manager().select_raid(raid_id)
|
||||
if selection.get("defense_open", false) is bool and selection.get("defense_open", false):
|
||||
_show_defense_manager()
|
||||
|
||||
|
||||
func _setup_ui_audio() -> void:
|
||||
_paired_bgm = PairedBGMScript.new()
|
||||
_paired_bgm.name = "PairedBGM"
|
||||
_paired_bgm.configure(GameState.active_layer)
|
||||
add_child(_paired_bgm)
|
||||
_audio_feedback = UIAudioFeedbackScript.new()
|
||||
_audio_feedback.name = "UIAudioFeedback"
|
||||
_audio_feedback.configure(_runtime, audio_settings_path)
|
||||
add_child(_audio_feedback)
|
||||
_audio_feedback.begin_loaded_epoch(_current_audio_foreground())
|
||||
_audio_settings_dialog = UIAudioSettingsDialogScript.new()
|
||||
_audio_settings_dialog.configure(_audio_feedback, _apply_button_style)
|
||||
add_child(_audio_settings_dialog)
|
||||
|
||||
|
||||
func _current_audio_foreground() -> Variant:
|
||||
var events: Variant = _service(&"event_service")
|
||||
if events != null and events.has_method(&"get_foreground_instance"):
|
||||
return events.call(&"get_foreground_instance")
|
||||
return null
|
||||
|
||||
|
||||
func _show_audio_settings() -> void:
|
||||
if _audio_settings_dialog != null:
|
||||
_audio_settings_dialog.show_settings()
|
||||
|
||||
|
||||
func _on_audio_world_selection(_id: StringName) -> void:
|
||||
if _audio_feedback != null:
|
||||
_audio_feedback.request_click()
|
||||
|
||||
|
||||
func _submit_ui_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if _audio_feedback != null:
|
||||
return _audio_feedback.submit_command(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
|
||||
@@ -82,6 +82,9 @@ var _drag_button := MOUSE_BUTTON_NONE
|
||||
|
||||
var _runtime: Variant = null
|
||||
var _hovered_cell: Variant = null
|
||||
var _preview_topology_key: Array = []
|
||||
var _connection_cache_key: Array = []
|
||||
var _connection_cache_report: Dictionary = {}
|
||||
var _last_preview_report: Dictionary = INVALID_PREVIEW.duplicate(true)
|
||||
var _last_excavation_preview_report: Dictionary = INVALID_EXCAVATION_PREVIEW.duplicate(true)
|
||||
|
||||
@@ -112,6 +115,8 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func set_runtime(value: Variant) -> void:
|
||||
_connection_cache_key.clear()
|
||||
_connection_cache_report.clear()
|
||||
_disconnect_runtime()
|
||||
_runtime = value
|
||||
_actor_tracks.clear()
|
||||
@@ -201,7 +206,11 @@ func get_build_preview_report(cell: Variant = null) -> Dictionary:
|
||||
build_rotation
|
||||
)
|
||||
if report != null and report.has_method(&"to_dictionary"):
|
||||
return report.call(&"to_dictionary") as Dictionary
|
||||
var result: Dictionary = report.call(&"to_dictionary")
|
||||
result[&"connection"] = _build_connection_report(
|
||||
_cells_from_serialized(result.get(&"cells", [])),
|
||||
Vector3i(target.x, target.y, layer_mode))
|
||||
return result
|
||||
return {
|
||||
&"can_build": _fallback_cells_are_available(Vector3i(target.x, target.y, layer_mode)),
|
||||
&"error_codes": [],
|
||||
@@ -338,6 +347,9 @@ func _process(delta: float) -> void:
|
||||
lens_material.set_shader_parameter("extent", size)
|
||||
lens_material.set_shader_parameter("progress", _layer_transition_elapsed / LAYER_TRANSITION_SECONDS)
|
||||
_layer_lens.visible = _layer_transition_elapsed < LAYER_TRANSITION_SECONDS
|
||||
if build_definition != null and _hovered_cell is Vector3i and _current_preview_topology() != _preview_topology_key:
|
||||
_update_build_preview()
|
||||
queue_redraw()
|
||||
var clock := get_node_or_null("/root/GameClock")
|
||||
if clock != null and bool(clock.get("paused")):
|
||||
return
|
||||
@@ -421,7 +433,13 @@ func _gui_input(event: InputEvent) -> void:
|
||||
accept_event()
|
||||
return
|
||||
if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
|
||||
set_zoom(zoom_level + (0.5 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -0.5), event.position)
|
||||
if pan_mode and not event.ctrl_pressed:
|
||||
var direction := 1.0 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -1.0
|
||||
pan_offset.y += direction * _cell_dimensions().y * maxf(event.factor, 0.0)
|
||||
_clamp_pan()
|
||||
queue_redraw()
|
||||
else:
|
||||
set_zoom(zoom_level + (0.5 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -0.5), event.position)
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventMouseMotion and _dragging:
|
||||
@@ -657,6 +675,16 @@ func _draw_congestion_overlay(bounds: Dictionary) -> void:
|
||||
draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), color, 2.0)
|
||||
|
||||
|
||||
## Visual-only level comes from the existing live FacilityInstance, not the
|
||||
## construction footprint dictionary. No upgrade or simulation value is written.
|
||||
func _facility_visual_level(instance_id: StringName) -> int:
|
||||
var service: Variant = _service(&"facility_service")
|
||||
if service == null or not service.has_method(&"get_instance"):
|
||||
return 1
|
||||
var instance: Variant = service.get_instance(instance_id)
|
||||
return int(instance.level) if instance != null else 1
|
||||
|
||||
|
||||
func _completed_facilities_for_draw() -> Array:
|
||||
if _drawing and _draw_cache.has(&"completed_facilities"):
|
||||
return _draw_cache[&"completed_facilities"]
|
||||
@@ -685,7 +713,8 @@ func _other_layer_rooms() -> Array[Dictionary]:
|
||||
if cells.is_empty(): continue
|
||||
var rect := _cell_bounds_rect(cells)
|
||||
if rect.intersects(Rect2(Vector2.ZERO, size)):
|
||||
rooms.append({&"id": facility[&"instance_id"], &"rect": rect, &"definition_id": facility[&"definition_id"], &"origin_x": cells[0].x})
|
||||
rooms.append({&"id": facility[&"instance_id"], &"rect": rect, &"definition_id": facility[&"definition_id"], &"origin_x": cells[0].x,
|
||||
&"level": _facility_visual_level(StringName(facility[&"instance_id"]))})
|
||||
for job: ConstructionJob in _construction_jobs_for_draw():
|
||||
if job.layer == layer_mode or job.state in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: continue
|
||||
if job.reserved_cells.is_empty(): continue
|
||||
@@ -748,7 +777,7 @@ func _draw_world_entity_icons() -> void:
|
||||
if not rect.intersects(Rect2(Vector2.ZERO,size)):
|
||||
continue
|
||||
var id := StringName(facility.get(&"definition_id",&""))
|
||||
PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x, _room_presentations.get(StringName(facility.get(&"instance_id", &"")), {}))
|
||||
PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x, _room_presentations.get(StringName(facility.get(&"instance_id", &"")), {}), _facility_visual_level(StringName(facility.get(&"instance_id", &""))))
|
||||
var instance_id := StringName(facility.get(&"instance_id",&""))
|
||||
# Thin status lamp conveys operational state without repainting a room.
|
||||
draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id))
|
||||
@@ -1255,7 +1284,7 @@ func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, re
|
||||
if staff and state == StaffRuntime.State.INCAPACITATED:
|
||||
mode = &"collapsed"
|
||||
elif receiving and activity in [&"standard_room", &"premium_room"]:
|
||||
mode = &"sleep"
|
||||
mode = &"standard_room_sleep" if activity == &"standard_room" else &"sleep"
|
||||
elif moving:
|
||||
mode = &"carry_walk" if carrying else &"walk"
|
||||
elif carrying:
|
||||
@@ -1322,6 +1351,7 @@ func _draw_build_preview() -> void:
|
||||
draw_rect(rect, fill)
|
||||
draw_rect(rect, edge, false, 2.0)
|
||||
|
||||
_draw_build_connection(_last_preview_report.get(&"connection", {}))
|
||||
|
||||
func _draw_excavation_preview() -> void:
|
||||
if not excavation_mode or not _hovered_cell is Vector3i:
|
||||
@@ -1434,6 +1464,7 @@ func _set_hovered_cell(value: Variant) -> void:
|
||||
|
||||
|
||||
func _update_build_preview() -> void:
|
||||
_preview_topology_key = _current_preview_topology()
|
||||
_last_preview_report = get_build_preview_report()
|
||||
build_preview_changed.emit(_last_preview_report.duplicate(true))
|
||||
|
||||
@@ -1451,6 +1482,8 @@ func _on_mouse_exited() -> void:
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||||
_connection_cache_key.clear()
|
||||
_connection_cache_report.clear()
|
||||
queue_redraw()
|
||||
_update_build_preview()
|
||||
_update_excavation_preview()
|
||||
@@ -1567,3 +1600,88 @@ func _can_drop_data(_position: Vector2, data: Variant) -> bool:
|
||||
func _drop_data(position: Vector2, data: Variant) -> void:
|
||||
if _can_drop_data(position, data):
|
||||
staff_assignment_dropped.emit(data, facility_at_canvas(position))
|
||||
|
||||
|
||||
func _current_preview_topology() -> Array:
|
||||
var world: Variant = _service(&"world_service")
|
||||
var path: Variant = _service(&"path_service")
|
||||
return [world.revision if world != null else -1,
|
||||
path.graph_revision if path != null else -1,
|
||||
path.traffic_revision if path != null else -1]
|
||||
|
||||
|
||||
## Read-only connection preview. Shares ordinary facility access candidates and
|
||||
## accessible graph edges, but does not issue live path queries or change routing.
|
||||
## The displayed path proves connectivity; it is not a cheapest-route promise.
|
||||
func _build_connection_report(cells: Array[Vector3i], origin: Vector3i) -> Dictionary:
|
||||
var path: Variant = _service(&"path_service")
|
||||
if path == null or _runtime == null or cells.is_empty():
|
||||
return {&"status": &"UNAVAILABLE", &"reachable": false, &"path": []}
|
||||
var entrance: Variant = _runtime.call(&"_operations_entrance_cell")
|
||||
var key: Array = [origin, cells, _current_preview_topology(), entrance]
|
||||
if key == _connection_cache_key:
|
||||
return _connection_cache_report.duplicate(true)
|
||||
var result := {&"status": &"UNAVAILABLE", &"reachable": false,
|
||||
&"entrance": entrance, &"origin": origin, &"path": [], &"access_points": []}
|
||||
if entrance is Vector3i:
|
||||
var future := FacilityInstance.new()
|
||||
future.origin = origin
|
||||
future.occupied_cells.assign(cells)
|
||||
var candidates: Array = _runtime.call(&"_facility_access_candidates", future)
|
||||
result[&"access_points"] = candidates
|
||||
var targets: Dictionary = {}
|
||||
for cell: Vector3i in candidates: targets[cell] = true
|
||||
var queue: Array[Vector3i] = [entrance]
|
||||
var parents: Dictionary = {}
|
||||
parents[entrance] = entrance
|
||||
var cursor := 0
|
||||
var target: Variant = null
|
||||
while cursor < queue.size():
|
||||
var current := queue[cursor]
|
||||
cursor += 1
|
||||
if targets.has(current):
|
||||
target = current
|
||||
break
|
||||
for edge: Dictionary in path.get_neighbors(current):
|
||||
var next: Vector3i = edge[&"to"]
|
||||
if parents.has(next) or path.get_edge_report(current, next).is_empty():
|
||||
continue
|
||||
parents[next] = current
|
||||
queue.append(next)
|
||||
var route: Array[Vector3i] = []
|
||||
if target is Vector3i:
|
||||
var current: Vector3i = target
|
||||
while current != entrance:
|
||||
route.push_front(current)
|
||||
current = parents[current]
|
||||
route.push_front(entrance)
|
||||
result[&"reachable"] = not route.is_empty()
|
||||
result[&"status"] = &"CONNECTED" if result[&"reachable"] else &"DISCONNECTED"
|
||||
result[&"path"] = route
|
||||
_connection_cache_key = key.duplicate(true)
|
||||
_connection_cache_report = result.duplicate(true)
|
||||
return result
|
||||
|
||||
|
||||
func _draw_build_connection(report: Dictionary) -> void:
|
||||
if report.is_empty() or not _hovered_cell is Vector3i: return
|
||||
var status: StringName = report.get(&"status", &"UNAVAILABLE")
|
||||
var color := Color("8ff0ab") if status == &"CONNECTED" else Color("ffd080")
|
||||
var target := cell_to_canvas(_hovered_cell)
|
||||
var route: Array = report.get(&"path", [])
|
||||
if status == &"CONNECTED":
|
||||
for i in range(1, route.size()):
|
||||
var from: Vector3i = route[i-1]
|
||||
var to: Vector3i = route[i]
|
||||
if from.z == layer_mode and to.z == layer_mode:
|
||||
draw_line(cell_to_canvas(from), cell_to_canvas(to), color, 2.0)
|
||||
if not route.is_empty() and (route.back() as Vector3i).z == layer_mode:
|
||||
draw_line(cell_to_canvas(route.back()), target, color, 2.0)
|
||||
elif report.get(&"entrance") is Vector3i:
|
||||
# Dashed straight guide is a warning, never a traversable-path claim.
|
||||
draw_dashed_line(cell_to_canvas(report[&"entrance"]), target, color, 2.0, 8.0)
|
||||
draw_line(target-Vector2(6,6),target+Vector2(6,6),color,2.0)
|
||||
draw_line(target+Vector2(-6,6),target+Vector2(6,-6),color,2.0)
|
||||
var message := "入口への経路接続済" if status == &"CONNECTED" else "入口への経路未接続" if status == &"DISCONNECTED" else "入口・経路を確認できません"
|
||||
draw_string(ThemeDB.fallback_font, target+Vector2(8,-12), message,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, color)
|
||||
|
||||
Reference in New Issue
Block a user