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:
Takeshi
2026-10-11 15:58:00 +09:00
parent 39f6068461
commit b84bade045
235 changed files with 495831 additions and 312 deletions
+254 -28
View File
@@ -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)
+123 -5
View File
@@ -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)