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") const StaffManagementPanelScript := preload("res://scripts/ui/staff_management_panel.gd") const SupplyManagementPanelScript := preload("res://scripts/ui/supply_management_panel.gd") const BusinessManagementPanelScript := preload("res://scripts/ui/business_management_panel.gd") const DefenseManagementPanelScript := preload("res://scripts/ui/defense_management_panel.gd") const WorldPreviewScript := preload("res://scenes/main/world_preview.gd") const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres") var _last_presented_close_minute := 0 const MANUAL_SAVE_PATH := "user://saves/manual_ui" const MVP_FACILITY_IDS: Array[StringName] = [ &"cleaning_room", &"chapel", &"reception", &"standard_room", &"premium_room", &"cafeteria", &"warehouse", &"kitchen", &"public_bath", &"staff_waiting_room", &"staff_dormitory", &"infirmary", &"security_station", &"tavern", &"massage_room", &"guest_elevator", &"service_elevator", &"guest_stairs", &"service_stairs", &"mana_warp", &"service_warp", &"staff_door", &"guest_corridor", &"service_corridor", &"adult_private_room", ] const FACILITY_STATE_NAMES := [ "故障", "到達不能", "魔力不足", "人員不足", "閉鎖中", "低下運転", "営業中", ] const ELEVATOR_PHASE_NAMES := {&"IDLE": "待機", &"PICKUP": "迎車中", &"BOARDING": "乗車中", &"TRAVEL": "運行中", &"ALIGHTING": "降車中"} const FACILITY_REASON_NAMES := { &"BROKEN": "故障", &"DESTROYED": "破壊による休止", &"RAIDED": "摘発による休止", &"RELATED_FACILITY_INACTIVE": "関連施設が未稼働", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力供給なし", &"NO_STAFF": "必要人員不足", &"CLOSED_BY_OPERATOR": "手動閉鎖", &"OUTSIDE_OPENING_HOURS": "営業時間外", &"LOW_DURABILITY": "耐久度低下", } const PREVIEW_REASON_NAMES := { "INVALID_DEFINITION": "施設データが不正です", "MISSING_DEPENDENCY": "建設システムを利用できません", "OUT_OF_BOUNDS": "世界の範囲外です", "ZONE_MISMATCH": "地上・境界・地下の条件が合いません", "LAYER_MISMATCH": "表層/裏層が合いません", "OVERLAP": "ほかの施設または工事と重なります", "UNSUPPORTED": "構造を支えられません", "INSUFFICIENT_RESOURCES": "建設資源が不足しています", } const EXCAVATION_REASON_NAMES := { &"OUT_OF_BOUNDS": "世界の範囲外です", &"NOT_UNDERGROUND": "地下のマスだけ掘削できます", &"ALREADY_EXCAVATED": "すでに掘削済みです", &"ALREADY_RESERVED": "このマスは掘削中です", &"NO_EXCAVATED_NEIGHBOR": "掘削済みの隣接マスから延ばしてください", &"NO_ENTRANCE_CONNECTION": "地上入口までつながる経路がありません", &"SUPPORT_REQUIRED": "硬い地盤を支える補強施設が必要です", &"INSUFFICIENT_RESOURCES": "掘削資源が不足しています", &"MISSING_DEPENDENCY": "掘削システムを利用できません", } const EVENT_CHOICE_NAMES := { &"event.relationship.threshold.acknowledge": "向き合う", &"event.relationship.threshold.defer": "今は保留する", } const STAFF_ROLE_NAMES := { &"attendant": "接客", &"bartender": "酒場", &"clerk": "会計", &"cleaner": "清掃", &"cook": "調理", &"guard": "警備", &"maintenance": "保守", &"masseur": "施術", &"medic": "医療", &"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者", } const STAFF_SHIFT_NAMES := { &"day": "日勤", &"night": "夜勤", &"any": "柔軟勤務", } const STAFF_SKILL_NAMES := { &"service": "接客", &"operation": "運営", &"combat": "戦闘", &"underworld": "裏社会", } @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 weather_forecast_label: Label var guest_count_label: Button var dimension_migration_dialog: ConfirmationDialog var _dimension_migration_digest := "" var guest_observer: AcceptDialog var lodging_count_label: Label var alert_count_label: Label var defense_manager: AcceptDialog var alert_icon_node: TextureRect var resource_labels: Dictionary = {} var resource_icon_nodes: Dictionary = {} var world_preview: WorldPreview var front_button: Button var back_button: Button var excavation_button: Button var demolition_button: Button var demolition_dialog: ConfirmationDialog var _demolition_mode := false var _demolition_target: StringName = &"" var _pending_demolition_commands: Dictionary = {} var pan_button: Button var pause_button: Button var speed_buttons: Dictionary = {} var dashboard_summary_label: Label var foreground_event_panel: VBoxContainer var foreground_event_label: Label var foreground_event_choices: VBoxContainer var staff_management_panel: VBoxContainer var staff_management_toggle_button: Button var _staff_management_panel_open := false var staff_candidate_panel: VBoxContainer var staff_candidate_summary_label: Label var staff_candidate_toggle_button: Button var staff_candidate_buttons: Dictionary = {} var selected_heading_label: Label var selected_detail_label: Label var related_facility_picker: OptionButton var _pending_related_commands: Dictionary = {} var selected_entity_icon: TextureRect var selected_elevator_controls: VBoxContainer var elevator_operation_button: Button var elevator_operation_hint: Label var build_status_label: Label var build_buttons: Dictionary = {} var navigation_buttons: Dictionary = {} var _interface_layer := -1 var opening_guide: Control var operating_ledger_panel: VBoxContainer var supply_management_panel: VBoxContainer var business_management_panel: VBoxContainer var _business_panel_open := false var _supply_panel_open := false var operations_controls: VBoxContainer var _ledger_panel_open := false var management_panel: Control var management_toggle_button: Button var palette_scroll: ScrollContainer var palette_toggle_button: Button var _management_panel_open := false var _build_palette_open := false var _runtime: Variant = null var _selected_entity_id: StringName = &"" var _selected_entity_kind: StringName = &"" var _selected_definition_id: StringName = &"" var _selected_definition: Variant = null var _build_rotation := 0 var _build_sequence := 1 var _excavation_sequence := 1 var _hire_sequence := 1 var _elevator_sequence := 1 var _pending_elevator_commands: Dictionary = {} var _pending_build_commands: Dictionary = {} var _excavation_mode := false var _pending_excavation_commands: Dictionary = {} var _pending_hire_commands: Dictionary = {} var _staff_candidate_panel_open := false var _operations_start_report: Dictionary = {} func _ready() -> void: _runtime = get_node_or_null("/root/SimulationRuntime") _pause_fresh_opening() if _runtime != null and _runtime.has_method("enable_production_save_policy"): _runtime.enable_production_save_policy() if _runtime != null \ and _runtime.has_method("get_operations_report") \ and _runtime.has_method("enable_production_operations"): var current_operations: Dictionary = _runtime.call(&"get_operations_report") 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() _populate_staff_candidates() _on_time_changed(GameClock.day, GameClock.get_hour(), GameClock.get_minute()) _on_phase_changed(GameClock.get_phase()) for resource_name: StringName in GameState.resources: _on_resource_changed(resource_name, GameState.get_resource(resource_name)) _set_layer(GameState.active_layer) _update_pause_and_speed() _refresh_dashboard() if not _operations_start_report.is_empty() \ and not bool(_operations_start_report.get(&"ok", false)): _set_status( "本番運営を開始できませんでした: %s" % _result_message(_operations_start_report), false ) _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() func _pause_fresh_opening() -> void: if _runtime == null or GameClock.absolute_minute != 360: return if not (_runtime.get(&"latest_restore_safety_report") as Dictionary).is_empty(): return var operations: Dictionary = _runtime.call(&"get_operations_report") if bool(operations.get(&"enabled", false)): return if not _call_array(_service(&"construction_service"), &"get_facilities").is_empty() \ or not _call_array(_service(&"construction_service"), &"get_jobs").is_empty() \ or not _call_array(_service(&"staff_service"), &"get_staff_members").is_empty(): return var queue: Variant = _runtime.get(&"command_queue") if queue != null and not queue.get_accepted_command_metadata().is_empty(): return GameClock.set_paused(true) func _unhandled_key_input(event: InputEvent) -> void: if not event.pressed or event.echo: return var handled := true match event.keycode: KEY_ESCAPE: _clear_active_tool() KEY_E: _toggle_excavation_mode() KEY_H: _toggle_staff_candidate_panel() KEY_B: _toggle_build_palette() KEY_I: _set_management_panel_open(not _management_panel_open) KEY_SPACE: _toggle_pause() KEY_TAB: _set_layer(1 - world_preview.layer_mode) KEY_1: _set_speed(1) KEY_2: _set_speed(2) KEY_3: _set_speed(3) KEY_R: if _selected_definition == null: handled = false else: _rotate_build_preview() _: handled = false if handled: if _audio_feedback != null: _audio_feedback.request_click() get_viewport().set_input_as_handled() func _build_interface() -> void: var background := ColorRect.new() background.name = "InterfaceBackground" background.color = _visual_color(&"window_background", Color("24170f")) background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(background) var root_margin := MarginContainer.new() root_margin.add_theme_constant_override("margin_left", 6) root_margin.add_theme_constant_override("margin_top", 6) root_margin.add_theme_constant_override("margin_right", 6) root_margin.add_theme_constant_override("margin_bottom", 6) root_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(root_margin) var layout := VBoxContainer.new() layout.add_theme_constant_override("separation", 4) root_margin.add_child(layout) layout.add_child(_create_top_bar()) var content := HBoxContainer.new() content.name = "MainContent" content.size_flags_vertical = Control.SIZE_EXPAND_FILL content.add_theme_constant_override("separation", 4) layout.add_child(content) content.add_child(_create_navigation_rail()) var map_panel := PanelContainer.new() map_panel.name = "MapPanel" map_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL map_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL map_panel.add_theme_stylebox_override( "panel", _themed_panel_style(&"panel_outer", _visual_color(&"window_background", Color("1e1712")), _visual_color(&"border_gold", Color("9b6a32")), 8, 5) ) content.add_child(map_panel) world_preview = WorldPreviewScript.new() world_preview.name = "WorldPreview" world_preview.custom_minimum_size = Vector2(600, 380) world_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL world_preview.size_flags_vertical = Control.SIZE_EXPAND_FILL world_preview.set_visual_theme(visual_theme) map_panel.add_child(world_preview) opening_guide = OpeningGuideScript.new() world_preview.add_child(opening_guide) opening_guide.setup(_runtime, GameClock, GameState.active_layer) opening_guide.position = Vector2(8, 8) opening_guide.size = Vector2(302, 0) opening_guide.action_requested.connect(_on_opening_action) # The inspector overlays the map only when requested, leaving the full # cross-section available while watching the simulation. management_panel = _create_side_panel() world_preview.add_child(management_panel) management_panel.set_anchors_and_offsets_preset(Control.PRESET_RIGHT_WIDE) management_panel.offset_left = -320 management_panel.offset_right = -8 management_panel.offset_top = 8 management_panel.offset_bottom = -8 management_panel.visible = false layout.add_child(_create_bottom_bar()) func _create_top_bar() -> Control: var panel := PanelContainer.new() panel.name = "TopBar" panel.custom_minimum_size.y = 46 panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_wood", _visual_color(&"dark_panel", Color("6b3e1d")), _visual_color(&"border_gold", Color("d5a552")), 8, 8)) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 7) panel.add_child(row) var title_box := VBoxContainer.new() title_box.add_theme_constant_override("separation", 0) row.add_child(title_box) var title := Label.new() title.text = "THE TREE" title.custom_minimum_size.x = 98 title.add_theme_font_size_override("font_size", 17) title.add_theme_color_override("font_color", Color("fff0c8")) title_box.add_child(title) rank_button=Button.new() rank_button.name="WorldTreeRankButton" rank_button.text="世界樹ランク 1" rank_button.flat=true rank_button.alignment=HORIZONTAL_ALIGNMENT_LEFT rank_button.add_theme_font_size_override("font_size",10) rank_button.add_theme_stylebox_override("normal",StyleBoxEmpty.new()) rank_button.tooltip_text="世界樹のランクと昇格条件を確認" 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() ) add_child(rank_dialog) rank_button.pressed.connect(rank_dialog.show_rank) row.add_child(VSeparator.new()) time_label = Label.new() time_label.custom_minimum_size.x = 104 time_label.add_theme_font_size_override("font_size", 12) time_label.add_theme_color_override("font_color", Color("fff5d9")) row.add_child(time_label) phase_label = Label.new() phase_label.custom_minimum_size.x = 48 phase_label.add_theme_font_size_override("font_size", 12) phase_label.add_theme_color_override("font_color", Color("ffd37a")) row.add_child(phase_label) weather_label = _hud_value_label("晴れ", 48) weather_label.name = "WeatherLabel" weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。" weather_label.mouse_filter = Control.MOUSE_FILTER_STOP row.add_child(weather_label) # A13 forecast: the next change of today's weather (kind and time). weather_forecast_label = _hud_value_label("", 72) weather_forecast_label.name = "WeatherForecastLabel" weather_forecast_label.mouse_filter = Control.MOUSE_FILTER_STOP row.add_child(weather_forecast_label) guest_count_label = Button.new() guest_count_label.text = "来客 0人" guest_count_label.tooltip_text = "来客の欲求と現在の目的地を見る" guest_count_label.custom_minimum_size.x = 72 guest_count_label.add_theme_font_size_override("font_size", 12) guest_count_label.pressed.connect(_show_guest_observer) lodging_count_label = _hud_value_label("宿泊 0人", 72) row.add_child(guest_count_label) row.add_child(lodging_count_label) for resource_name: StringName in [&"gold", &"mana", &"materials", &"faith"]: var resource_block := HBoxContainer.new() resource_block.add_theme_constant_override("separation", 2) var resource_icon := TextureRect.new() resource_icon.name = "%sIcon" % String(resource_name).capitalize() resource_icon.texture = visual_theme.resource_icon(resource_name) if visual_theme != null else null resource_icon.custom_minimum_size = Vector2(16, 16) resource_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE resource_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED resource_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE resource_icon_nodes[resource_name] = resource_icon resource_block.add_child(resource_icon) var label := _hud_value_label("", 54) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT resource_labels[resource_name] = label resource_block.add_child(label) row.add_child(resource_block) alert_count_label = _hud_value_label("警戒 0", 64) alert_count_label.mouse_filter = Control.MOUSE_FILTER_STOP alert_count_label.focus_mode = Control.FOCUS_ALL alert_count_label.gui_input.connect(_on_defense_notice_input) alert_count_label.add_theme_color_override("font_color", Color("ffb095")) alert_icon_node = _icon_rect(visual_theme.control_icon(&"alert") if visual_theme != null else null, 20) alert_icon_node.name = "AlertIcon" row.add_child(alert_icon_node) row.add_child(alert_count_label) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(spacer) pause_button = _button("停止", _toggle_pause, 52) row.add_child(pause_button) for value: int in [1, 2, 3]: var speed_button := _button("x%d" % value, _set_speed.bind(value), 42) speed_button.toggle_mode = true speed_buttons[value] = speed_button row.add_child(speed_button) return panel func _create_navigation_rail() -> Control: var panel := PanelContainer.new() panel.name = "NavigationRail" panel.custom_minimum_size.x = 64 panel.size_flags_vertical = Control.SIZE_EXPAND_FILL panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_leather", _visual_color(&"dark_panel_alt", Color("513018")), _visual_color(&"border_gold", Color("bd8540")), 8, 7)) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 4) panel.add_child(column) var entries := [ [&"staff", "従業員"], [&"facility", "施設"], [&"product", "商品"], [&"business", "営業"], [&"explore", "探索"], [&"investment", "投資"], [&"ledger", "帳簿"], ] for entry: Array in entries: var mode := StringName(entry[0]) var button := _button(String(entry[1]), _activate_management_mode.bind(mode), 50) button.custom_minimum_size.y = 46 button.toggle_mode = true button.add_theme_font_size_override("font_size", 12) _apply_button_icon(button, visual_theme.navigation_icon(&"ledger" if mode == &"business" else mode) if visual_theme != null else null, 16) navigation_buttons[mode] = button column.add_child(button) var nav_spacer := Control.new() nav_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL column.add_child(nav_spacer) var layer_hint := Label.new() layer_hint.text = "TAB\n表 / 裏" layer_hint.tooltip_text = "ホイール: 拡大縮小\n画面移動ボタン → 左ドラッグ: 移動\n中ボタンドラッグ: 移動\nHome: 全体表示" layer_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER layer_hint.add_theme_font_size_override("font_size", 10) layer_hint.add_theme_color_override("font_color", Color("e2c38c")) column.add_child(layer_hint) return panel func _create_side_panel() -> Control: var panel := PanelContainer.new() panel.name = "SidePanel" panel.custom_minimum_size.x = 312 panel.size_flags_vertical = Control.SIZE_EXPAND_FILL panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_parchment", _visual_color(&"light_panel", Color("f4e3bd")), _visual_color(&"border_gold", Color("a96f2f")), 8, 10)) var scroll := ScrollContainer.new() scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL panel.add_child(scroll) var column := VBoxContainer.new() column.custom_minimum_size.x = 278 column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 8) scroll.add_child(column) var root_column := column var inspector_heading := HBoxContainer.new() var inspector_title := _section_heading("運営状況") inspector_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL inspector_heading.add_child(inspector_title) inspector_heading.add_child(_button("閉じる I", _set_management_panel_open.bind(false), 64)) column.add_child(inspector_heading) dashboard_summary_label = Label.new() dashboard_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART dashboard_summary_label.add_theme_color_override("font_color", Color("4d341f")) dashboard_summary_label.add_theme_font_size_override("font_size", 13) column.add_child(dashboard_summary_label) foreground_event_panel = VBoxContainer.new() foreground_event_panel.name = "ForegroundEventPanel" foreground_event_panel.add_theme_constant_override("separation", 6) foreground_event_panel.visible = false column.add_child(foreground_event_panel) foreground_event_panel.add_child(_section_heading("要対応イベント")) foreground_event_label = Label.new() foreground_event_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART foreground_event_label.add_theme_color_override("font_color", Color("8f3f29")) foreground_event_label.add_theme_font_size_override("font_size", 13) foreground_event_panel.add_child(foreground_event_label) foreground_event_choices = VBoxContainer.new() foreground_event_choices.name = "ForegroundEventChoices" foreground_event_choices.add_theme_constant_override("separation", 5) foreground_event_panel.add_child(foreground_event_choices) column.add_child(HSeparator.new()) operations_controls = VBoxContainer.new() operations_controls.name = "OperationsControls" operations_controls.add_theme_constant_override("separation", 8) column.add_child(operations_controls) column = operations_controls var candidate_heading_row := HBoxContainer.new() candidate_heading_row.add_child(_section_heading("スタッフ")) var candidate_heading_spacer := Control.new() candidate_heading_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL candidate_heading_row.add_child(candidate_heading_spacer) var defense_button := _button("防衛", _show_defense_manager, 52) defense_button.name = "DefenseManagementButton" candidate_heading_row.add_child(defense_button) staff_management_toggle_button = _button("雇用済み", _toggle_staff_management_panel, 62) staff_management_toggle_button.name = "StaffManagementToggleButton" staff_management_toggle_button.toggle_mode = true candidate_heading_row.add_child(staff_management_toggle_button) staff_candidate_toggle_button = _button( "候補を見る H", _toggle_staff_candidate_panel, 108 ) staff_candidate_toggle_button.name = "StaffCandidateToggleButton" candidate_heading_row.add_child(staff_candidate_toggle_button) 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) staff_candidate_panel = VBoxContainer.new() staff_candidate_panel.name = "StaffCandidatePanel" staff_candidate_panel.visible = false staff_candidate_panel.add_theme_constant_override("separation", 7) column.add_child(staff_candidate_panel) staff_candidate_summary_label = Label.new() staff_candidate_summary_label.name = "StaffCandidateSummary" staff_candidate_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART staff_candidate_summary_label.add_theme_color_override("font_color", Color("765438")) staff_candidate_summary_label.add_theme_font_size_override("font_size", 12) staff_candidate_panel.add_child(staff_candidate_summary_label) var candidate_list := VBoxContainer.new() candidate_list.name = "StaffCandidateList" candidate_list.add_theme_constant_override("separation", 7) staff_candidate_panel.add_child(candidate_list) column.add_child(HSeparator.new()) selected_heading_label = _section_heading("選択中の施設") column.add_child(selected_heading_label) var selected_row := HBoxContainer.new() selected_row.add_theme_constant_override("separation", 8) column.add_child(selected_row) selected_entity_icon = TextureRect.new() selected_entity_icon.name = "SelectedEntityIcon" selected_entity_icon.custom_minimum_size = Vector2(24, 24) selected_entity_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE selected_entity_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED selected_entity_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE selected_row.add_child(selected_entity_icon) selected_detail_label = Label.new() selected_detail_label.mouse_filter = Control.MOUSE_FILTER_PASS selected_detail_label.text = "地図上の施設をクリックすると、営業状態を確認できます。" selected_detail_label.custom_minimum_size.y = 112 selected_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL selected_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART selected_detail_label.add_theme_color_override("font_color", Color("4d341f")) selected_detail_label.add_theme_font_size_override("font_size", 13) selected_row.add_child(selected_detail_label) related_facility_picker = OptionButton.new() related_facility_picker.name = "RelatedFacilityPicker" related_facility_picker.visible = false related_facility_picker.tooltip_text = "自施設の稼働に必要な関連施設を指定します。休止状態は解除しません。" related_facility_picker.item_selected.connect(_on_related_facility_selected) column.add_child(related_facility_picker) selected_elevator_controls = VBoxContainer.new() selected_elevator_controls.name = "SelectedElevatorControls" selected_elevator_controls.visible = false column.add_child(selected_elevator_controls) elevator_operation_button = _button("昇降機を停止", _on_elevator_operation_pressed, 160) elevator_operation_button.name = "ElevatorOperationButton" selected_elevator_controls.add_child(elevator_operation_button) elevator_operation_hint = Label.new() elevator_operation_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART elevator_operation_hint.add_theme_font_size_override("font_size", 12) elevator_operation_hint.add_theme_color_override("font_color", Color("654d37")) selected_elevator_controls.add_child(elevator_operation_hint) 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 root_column.add_child(business_management_panel) return panel func _create_bottom_bar() -> Control: var panel := PanelContainer.new() panel.name = "BottomBar" panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_wood", _visual_color(&"dark_panel", Color("24382e")), _visual_color(&"border_gold", Color("7d8862")), 0, 5)) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 4) panel.add_child(column) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 5) column.add_child(row) palette_toggle_button = _button("建築 B", _toggle_build_palette, 76) palette_toggle_button.name = "BuildPaletteToggleButton" palette_toggle_button.toggle_mode = true row.add_child(palette_toggle_button) front_button = _button("客用・表層", _set_layer.bind(0), 96) back_button = _button("裏方・裏層", _set_layer.bind(1), 96) _apply_button_icon(front_button, visual_theme.control_icon(&"front_layer") if visual_theme != null else null, 16) _apply_button_icon(back_button, visual_theme.control_icon(&"back_layer") if visual_theme != null else null, 16) front_button.toggle_mode = true back_button.toggle_mode = true row.add_child(front_button) row.add_child(back_button) excavation_button = _button("掘削 E", _toggle_excavation_mode, 72) excavation_button.name = "ExcavationButton" excavation_button.toggle_mode = true _apply_button_icon(excavation_button, visual_theme.control_icon(&"excavate") if visual_theme != null else null, 16) row.add_child(excavation_button) row.add_child(VSeparator.new()) var rotate_button := _button("回転 R", _rotate_build_preview, 72) _apply_button_icon(rotate_button, visual_theme.control_icon(&"rotate") if visual_theme != null else null, 16) row.add_child(rotate_button) row.add_child(_button("解除 Esc", _clear_active_tool, 80)) pan_button = _button("画面移動", _toggle_pan_mode, 90) pan_button.name = "PanModeButton" pan_button.toggle_mode = true 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" focus_button.tooltip_text = "現在の層の建物を見やすい大きさで表示。Homeで全体表示。" row.add_child(focus_button) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(spacer) management_toggle_button = _button("運営 I", _toggle_management_panel, 72) 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 = "読込 ▾" load_menu.custom_minimum_size = Vector2(64,28) load_menu.add_theme_font_size_override("font_size",12) _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 == 2: _show_manual_load_slots()) row.add_child(load_menu) palette_scroll = ScrollContainer.new() palette_scroll.name = "BuildPaletteScroll" palette_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO palette_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED palette_scroll.custom_minimum_size.y = 62 palette_scroll.visible = false column.add_child(palette_scroll) var palette_row := HBoxContainer.new() palette_row.name = "BuildPaletteRow" palette_row.add_theme_constant_override("separation", 4) palette_scroll.add_child(palette_row) var grid := GridContainer.new() grid.name = "BuildPalette" grid.columns = MVP_FACILITY_IDS.size() grid.add_theme_constant_override("h_separation", 4) grid.add_theme_constant_override("v_separation", 0) palette_row.add_child(grid) demolition_button = _button("撤去", _toggle_demolition_mode, 72) demolition_button.name = "DemolitionButton" demolition_button.toggle_mode = true palette_row.add_child(demolition_button) build_status_label = Label.new() build_status_label.name = "BuildStatusLabel" build_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL build_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS build_status_label.add_theme_font_size_override("font_size", 11) build_status_label.add_theme_color_override("font_color", Color("c7d3a8")) column.add_child(build_status_label) return panel func _toggle_build_palette() -> void: _set_build_palette_open(not _build_palette_open) func _set_build_palette_open(open: bool) -> void: _build_palette_open = open if palette_scroll != null: palette_scroll.visible = open if palette_toggle_button != null: palette_toggle_button.set_pressed_no_signal(open) palette_toggle_button.text = "建築を閉じる B" if open else "建築 B" if not open: var bottom := find_child("BottomBar", true, false) as Control if bottom != null: bottom.reset_size.call_deferred() func _toggle_management_panel() -> void: _set_management_panel_open(not _management_panel_open) func _set_management_panel_open(open: bool) -> void: _management_panel_open = open if management_panel != null: management_panel.visible = open if management_toggle_button != null: management_toggle_button.set_pressed_no_signal(open) if not open: _set_staff_candidate_panel_open(false) _set_staff_management_panel_open(false) _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_business_panel_open(false) func _activate_management_mode(mode: StringName) -> void: _set_management_panel_open(mode != &"facility") _set_supply_panel_open(mode == &"product") _set_business_panel_open(mode == &"business") _set_ledger_panel_open(mode == &"ledger") for raw_mode: Variant in navigation_buttons: (navigation_buttons[raw_mode] as Button).set_pressed_no_signal(StringName(raw_mode) == mode) match mode: &"staff": _set_staff_management_panel_open(true) &"facility": _set_build_palette_open(true) _set_staff_candidate_panel_open(false) _set_status("建築する施設を下のパレットから選んでください。", true, true) &"business": _set_status("施設ごとの料金と新規受付の開閉を設定できます。", true, true) &"product": _set_status("倉庫への仕入れ、厨房の自動生産、施設への配送を確認できます。", true, true) &"explore": _set_status("探索管理は一覧画面の実装準備中です。", true, true) &"investment": _set_status("投資管理は一覧画面の実装準備中です。", true, true) &"ledger": _set_status("本日の資金増減と、00:00に確定した営業日の損益を表示します。", true, true) func _set_ledger_panel_open(open: bool) -> void: _ledger_panel_open = open if open: _set_supply_panel_open(false) _set_business_panel_open(false) if operating_ledger_panel != null: operating_ledger_panel.visible = open if open: operating_ledger_panel.refresh() if operations_controls != null: operations_controls.visible = not open and not _supply_panel_open and not _business_panel_open if dashboard_summary_label != null: dashboard_summary_label.visible = not open and not _supply_panel_open and not _business_panel_open if open: _set_staff_candidate_panel_open(false) _set_staff_management_panel_open(false) func _set_supply_panel_open(open: bool) -> void: _supply_panel_open = open if open: _set_business_panel_open(false) _set_ledger_panel_open(false) _set_staff_candidate_panel_open(false) _set_staff_management_panel_open(false) if supply_management_panel != null: supply_management_panel.visible = open if open: supply_management_panel.refresh() if operations_controls != null: operations_controls.visible = not open and not _ledger_panel_open and not _business_panel_open if dashboard_summary_label != null: dashboard_summary_label.visible = not open and not _ledger_panel_open and not _business_panel_open func _set_business_panel_open(open: bool) -> void: _business_panel_open = open if open: _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_staff_candidate_panel_open(false) _set_staff_management_panel_open(false) if business_management_panel != null: business_management_panel.visible = open if open: business_management_panel.refresh() if operations_controls != null: operations_controls.visible = not open and not _ledger_panel_open and not _supply_panel_open if dashboard_summary_label != null: dashboard_summary_label.visible = not open and not _ledger_panel_open and not _supply_panel_open func _on_opening_action(action: StringName) -> void: match action: &"build_reception", &"build_room", &"build_corridor", &"build_stairs": _set_ledger_panel_open(false) _set_management_panel_open(false) _set_build_palette_open(true) var definitions := {&"build_reception": &"reception", &"build_room": &"standard_room", &"build_corridor": &"guest_corridor", &"build_stairs": &"guest_stairs"} _select_build_definition(definitions[action]) &"staff": _activate_management_mode(&"staff") if _call_array(_service(&"staff_service"), &"get_staff_members").is_empty(): _set_staff_candidate_panel_open(true) &"resume": if GameClock.paused: _toggle_pause() &"ledger": _activate_management_mode(&"ledger") func _connect_signals() -> void: GameClock.time_changed.connect(_on_time_changed) 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) if _runtime != null and _runtime is Object: if _runtime.has_signal(&"state_updated"): _runtime.connect(&"state_updated", Callable(self, "_on_snapshot_published")) elif _runtime.has_signal(&"snapshot_published"): _runtime.connect(&"snapshot_published", Callable(self, "_on_snapshot_published")) if _runtime.has_signal(&"checkpoint_presentation_requested"): _runtime.connect(&"checkpoint_presentation_requested", Callable(self, "_on_checkpoint_presentation_requested")) if _runtime.has_signal(&"save_operation_completed"): _runtime.connect(&"save_operation_completed", Callable(self, "_on_save_operation_completed")) if _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", Callable(self, "_on_command_completed")) func _populate_build_palette() -> void: var palette := find_child("BuildPalette", true, false) as GridContainer if palette == null: return var registry: Variant = _service(&"definition_registry") for definition_id: StringName in MVP_FACILITY_IDS: var definition: Variant = null if registry != null and registry.has_method(&"get_facility"): definition = registry.call(&"get_facility", definition_id) if definition == null: continue var button := Button.new() button.toggle_mode = true button.custom_minimum_size = Vector2(116, 50) button.text = _build_button_text(definition) button.tooltip_text = String(_definition_value(definition, &"description", "")) button.add_theme_font_size_override("font_size", 12) _apply_button_icon(button, visual_theme.facility_icon(definition_id) if visual_theme != null else null, 22) _apply_button_style(button) button.toggled.connect(_on_build_button_toggled.bind(definition_id)) build_buttons[definition_id] = button palette.add_child(button) if build_buttons.is_empty(): _set_status("施設定義を読み込めませんでした。", false) else: _set_status("B 建築 / H 雇用 / I 運営 ホイール 拡大縮小・中ボタン 移動・Home 全体表示", true, true) func _populate_staff_candidates() -> void: var list := find_child("StaffCandidateList", true, false) as VBoxContainer if list == null: return for child: Node in list.get_children(): list.remove_child(child) child.queue_free() staff_candidate_buttons.clear() var summaries := _staff_candidate_summaries() for summary: Dictionary in summaries: var candidate_id := StringName(summary.get(&"candidate_id", &"")) if candidate_id.is_empty(): continue var card := PanelContainer.new() card.name = "Candidate_%s" % String(candidate_id) card.add_theme_stylebox_override( "panel", _themed_panel_style(&"panel_leather", Color("132630"), Color("31505d"), 7, 8) ) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 4) card.add_child(content) var detail := Label.new() detail.name = "CandidateDetail_%s" % String(candidate_id) detail.text = _staff_candidate_text(summary) detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail.add_theme_color_override("font_color", Color("c8d7dc")) detail.add_theme_font_size_override("font_size", 12) content.add_child(detail) var hire_button := Button.new() hire_button.name = "Hire_%s" % String(candidate_id) hire_button.text = "無料で加入" if bool(summary.get(&"unique", false)) else "雇用する" hire_button.custom_minimum_size.y = 30 _apply_button_style(hire_button) hire_button.pressed.connect(_hire_staff_candidate.bind(candidate_id)) content.add_child(hire_button) staff_candidate_buttons[candidate_id] = hire_button list.add_child(card) _refresh_staff_candidates() func _staff_candidate_summaries() -> Array[Dictionary]: if _runtime == null or not _runtime.has_method(&"get_mvp_staff_candidate_summaries"): return [] var value: Variant = _runtime.call(&"get_mvp_staff_candidate_summaries") var result: Array[Dictionary] = [] if value is Array: for item: Variant in value: if item is Dictionary: result.append((item as Dictionary).duplicate(true)) return result func _staff_candidate_text(summary: Dictionary) -> String: var candidate_id := StringName(summary.get(&"candidate_id", &"")) var display_name := String(summary.get(&"display_name", candidate_id)) var role_id := StringName(summary.get(&"role_id", &"")) var shift_id := StringName(summary.get(&"preferred_shift", &"any")) var skills_value: Variant = summary.get(&"skills", {}) var skills: Dictionary = skills_value if skills_value is Dictionary else {} var skill_parts: Array[String] = [] for skill_id: StringName in [&"service", &"operation", &"combat", &"underworld"]: skill_parts.append("%s%d" % [ String(STAFF_SKILL_NAMES.get(skill_id, String(skill_id))), int(skills.get(skill_id, 0)), ]) return "%s(%d歳) %s / %s\n契約 G%d 日給 G%d\n技能 %s" % [ display_name, int(summary.get(&"age", 0)), String(STAFF_ROLE_NAMES.get(role_id, String(role_id))), String(STAFF_SHIFT_NAMES.get(shift_id, String(shift_id))), int(summary.get(&"signing_cost", 0)), int(summary.get(&"daily_wage", 0)), "・".join(skill_parts), ] func _toggle_staff_management_panel() -> void: _set_staff_management_panel_open(not _staff_management_panel_open) func _set_staff_management_panel_open(open: bool) -> void: _staff_management_panel_open = open if staff_management_panel != null: staff_management_panel.visible = open if staff_management_toggle_button != null: staff_management_toggle_button.set_pressed_no_signal(open) if open: _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_business_panel_open(false) _set_staff_candidate_panel_open(false) _set_management_panel_open(true) staff_management_panel.refresh() _set_status("従業員を選び、配属先と毎日の勤務帯を変更できます。", true, true) func _on_staff_management_status(message: String, success: bool) -> void: _set_status(message, success) func _toggle_staff_candidate_panel() -> void: _set_staff_candidate_panel_open(not _staff_candidate_panel_open) func _set_staff_candidate_panel_open(open: bool) -> void: _staff_candidate_panel_open = open if staff_candidate_panel != null: staff_candidate_panel.visible = open _refresh_staff_candidates() if open: _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_business_panel_open(false) _set_staff_management_panel_open(false) _set_management_panel_open(true) _set_status("雇用候補を選んでください。契約金と日給は確定前に表示されます。", true, true) func _refresh_staff_candidates() -> void: if staff_candidate_panel == null or staff_candidate_summary_label == null: return var summaries := _staff_candidate_summaries() var hired_count := 0 for summary: Dictionary in summaries: var candidate_id := StringName(summary.get(&"candidate_id", &"")) var hired := bool(summary.get(&"hired", false)) if hired: hired_count += 1 var button := staff_candidate_buttons.get(candidate_id) as Button if button == null: continue var pending := _pending_hire_commands.values().has(candidate_id) button.disabled = hired or pending button.text = ("加入済み" if bool(summary.get(&"unique", false)) else "雇用済み") if hired else "手続き中…" if pending else ("無料で加入" if bool(summary.get(&"unique", false)) else "雇用する") staff_candidate_summary_label.text = "未雇用 %d人 / 雇用済み %d人" % [ maxi(summaries.size() - hired_count, 0), hired_count, ] if staff_candidate_toggle_button != null: staff_candidate_toggle_button.text = ( "候補を閉じる H" if _staff_candidate_panel_open else "候補を見る H(%d)" % maxi(summaries.size() - hired_count, 0) ) func _hire_staff_candidate(candidate_id: StringName) -> void: if _runtime == null or not _runtime.has_method(&"submit_command"): _set_status("雇用手続きを送信できません。", false) return if _pending_hire_commands.values().has(candidate_id): _set_status("この候補の雇用手続きはすでに受理されています。", false) return var summaries := _staff_candidate_summaries() var selected: Dictionary = {} for summary: Dictionary in summaries: if StringName(summary.get(&"candidate_id", &"")) == candidate_id: selected = summary break if selected.is_empty(): _set_status("雇用候補を読み込めませんでした。", false) return if bool(selected.get(&"hired", false)): _set_status("%sはすでに雇用済みです。" % String(selected.get(&"display_name", candidate_id)), false) _refresh_staff_candidates() return var sequence := _hire_sequence _hire_sequence += 1 var idempotency_key := StringName( "ui.hire.%s.%08d" % [String(candidate_id), sequence] ) var result: Variant = _submit_ui_command(&"hire_staff_candidate", {&"candidate_id": candidate_id}, &"player", candidate_id, 75, -1, idempotency_key ) if result == null or not result.has_method(&"is_success"): _set_status("雇用手続きへ応答がありません。", false) return if not bool(result.call(&"is_success")): _set_status(_hire_failure_message(result), false) _refresh_staff_candidates() return var display_name := String(selected.get(&"display_name", candidate_id)) if result.details is Dictionary and result.details.has(&"staff_id"): _set_status("%sを雇用しました(契約 G%d / 日給 G%d)。" % [ display_name, int(selected.get(&"signing_cost", 0)), int(selected.get(&"daily_wage", 0)), ], true) else: _pending_hire_commands[int(result.command_id)] = candidate_id _set_status("%sの雇用手続きを受理しました。" % display_name, true) _refresh_dashboard() func _hire_failure_message(result: Variant) -> String: var details: Variant = result.details if details is Dictionary: var reason := StringName(details.get(&"reason", &"")) match reason: &"ARREARS_REMAIN", &"UNPAID_EXPENSES": return "雇用不可: 未払経費 G%dを清算してください。" % int(details.get(&"arrears_total", 0)) &"BANKRUPT": return "雇用不可: 資金破綻後は新しい契約を結べません。" &"EXTERNAL_ROLLBACK_FAILED": return "雇用不可: 運営データの復旧が必要です。" var message := String(result.message).to_lower() if int(result.code) == int(CommandResult.Code.DUPLICATE_COMMAND_ID): return "この候補はすでに雇用済みです。" if "insufficient" in message or "balance" in message: return "雇用不可: 契約金が不足しています。" return _command_failure_message(result) func _build_button_text(definition: Variant) -> String: var name := String(_definition_value(definition, &"display_name", _definition_value(definition, &"id", "施設"))) var cost_value: Variant = _definition_value(definition, &"construction_cost", {}) var gold := 0 var materials := 0 if cost_value is Dictionary: gold = int(cost_value.get(&"gold", cost_value.get("gold", 0))) materials = int(cost_value.get(&"materials", cost_value.get("materials", 0))) return "%s\nG %d / 資材 %d" % [name, gold, materials] func _on_build_button_toggled(pressed: bool, definition_id: StringName) -> void: if not pressed: if _selected_definition_id == definition_id: _clear_build_selection() return _select_build_definition(definition_id) func _select_build_definition(definition_id: StringName) -> void: var registry: Variant = _service(&"definition_registry") if registry == null or not registry.has_method(&"get_facility"): _set_status("建設パレットを利用できません。", false) return var definition: Variant = registry.call(&"get_facility", definition_id) if definition == null: _set_status("選択した施設定義が見つかりません。", false) return _set_demolition_mode(false, false) _set_excavation_mode(false, false) _set_pan_mode(false) _selected_definition_id = definition_id _selected_definition = definition _build_rotation = 0 for raw_id: Variant in build_buttons: (build_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == definition_id) var target_layer := clampi(int(_definition_value(definition, &"layer", 0)), 0, 1) _set_layer(target_layer) world_preview.set_build_definition(definition, _build_rotation) _set_status("%sを選択中。配置するマスをクリックしてください。" % String(_definition_value(definition, &"display_name", definition_id)), true, true) func _clear_build_selection(announce := true) -> void: _selected_definition_id = &"" _selected_definition = null _build_rotation = 0 for button_value: Variant in build_buttons.values(): (button_value as Button).set_pressed_no_signal(false) if world_preview != null: world_preview.clear_build_definition() if announce: _set_status("建設選択を解除しました。", true, true) func _clear_active_tool() -> void: if _demolition_mode: _set_demolition_mode(false) return if world_preview.pan_mode: _set_pan_mode(false) _set_status("画面移動を解除しました。施設をクリックすると情報を表示します。", true, true) return if _supply_panel_open or _business_panel_open: _set_management_panel_open(false) return if _staff_management_panel_open: _set_staff_management_panel_open(false) return if _staff_candidate_panel_open: _set_staff_candidate_panel_open(false) return if _excavation_mode: _set_excavation_mode(false) return _clear_build_selection() _set_build_palette_open(false) _set_management_panel_open(false) func _toggle_excavation_mode() -> void: _set_excavation_mode(not _excavation_mode) func _toggle_pan_mode() -> void: _set_pan_mode(not world_preview.pan_mode) _set_status("画面移動中:左ドラッグで移動、ホイールで上下移動、Ctrl+ホイールで拡大縮小。Escで解除。" if world_preview.pan_mode else "画面移動を解除しました。施設をクリックすると情報を表示します。", true, true) func _set_pan_mode(enabled: bool) -> void: if enabled: _set_demolition_mode(false, false) _clear_build_selection(false) _set_excavation_mode(false, false) _set_build_palette_open(false) world_preview.set_pan_mode(enabled) if pan_button != null: pan_button.set_pressed_no_signal(enabled) func _set_excavation_mode(enabled: bool, announce := true) -> void: if enabled: _set_demolition_mode(false, false) _set_pan_mode(false) _excavation_mode = enabled if excavation_button != null: excavation_button.set_pressed_no_signal(_excavation_mode) if world_preview != null: world_preview.set_excavation_mode(_excavation_mode) if not _excavation_mode: if announce: _set_status("掘削モードを解除しました。", true, true) return _clear_build_selection(false) _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" selected_heading_label.text = "掘削モード" selected_detail_label.text = "地下のマスにカーソルを合わせると、費用と所要時間を確認できます。" var gate_message := _excavation_gate_message() _set_status( gate_message if not gate_message.is_empty() else "地下の掘削候補を選んでください。FRONT / BACKは同時に開通します。", gate_message.is_empty(), true ) func _rotate_build_preview() -> void: if _selected_definition == null: _set_status("先に建設する施設を選択してください。", false, true) return var allowed_value: Variant = _definition_value(_selected_definition, &"allowed_rotations", [0]) var allowed: Array[int] = [] if allowed_value is Array: for rotation_value: Variant in allowed_value: allowed.append(int(rotation_value)) allowed.sort() if allowed.is_empty(): allowed = [0] var index := allowed.find(_build_rotation) _build_rotation = allowed[(index + 1) % allowed.size()] world_preview.set_build_rotation(_build_rotation) _set_status("回転 %d°。地図のマスをクリックして建設します。" % _build_rotation, true, true) func _on_map_cell_clicked(cell: Vector3i) -> void: if _audio_feedback != null: _audio_feedback.request_click() if _demolition_mode: _preview_demolition(cell) return if _excavation_mode: _start_excavation(cell) return if _selected_definition == null: _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" selected_elevator_controls.visible = false selected_entity_icon.texture = null selected_heading_label.text = "選択中のマス" selected_detail_label.text = "座標 (%d, %d)\nレイヤー %s\n\n施設のないマスです。" % [cell.x, cell.y, "FRONT / 表層" if cell.z == 0 else "BACK / 裏層"] 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"): _set_status("Simulation Runtimeへ接続できません。", false) return var idempotency_key := StringName("ui.build.%08d" % _build_sequence) _build_sequence += 1 var result: Variant = _submit_ui_command(&"build_facility", {&"definition_id": _selected_definition_id, &"origin": cell, &"rotation": _build_rotation}, &"player", &"", 50, -1, idempotency_key ) if result == null or not result.has_method(&"is_success"): _set_status("建設指示を送信できませんでした。", false) return if not bool(result.call(&"is_success")): _set_status(_command_failure_message(result), false) return var name := String(_definition_value(_selected_definition, &"display_name", _selected_definition_id)) if result.details is Dictionary and result.details.has(&"job_id"): _set_status("%sの建設を開始しました(%d, %d / %d°)。" % [name, cell.x, cell.y, _build_rotation], true) else: _pending_build_commands[int(result.command_id)] = _selected_definition_id _set_status("%sの建設指示を受理しました(%d, %d / %d°)。" % [name, cell.x, cell.y, _build_rotation], true) _refresh_dashboard() func _on_build_preview_changed(report: Dictionary) -> void: if _selected_definition == null or world_preview.get_hovered_cell() == null: return if bool(report.get(&"can_build", false)): var cell: Vector3i = world_preview.get_hovered_cell() 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) func _on_excavation_preview_changed(report: Dictionary) -> void: if not _excavation_mode or world_preview.get_hovered_cell() == null: return var gate_message := _excavation_gate_message() if not gate_message.is_empty(): _set_status("掘削不可: %s%s" % [gate_message, _excavation_cost_and_time(report)], false, true) return var cell: Vector3i = world_preview.get_hovered_cell() if bool(report.get(&"ok", false)): _set_status( "掘削可能 (%d, %d)・%s%s — クリックでFRONT / BACKを開通" % [ cell.x, cell.y, "FRONT / 表層" if cell.z == 0 else "BACK / 裏層", _excavation_cost_and_time(report), ], true, true ) else: _set_status(_excavation_failure_message(report), false, true) func _start_excavation(cell: Vector3i) -> void: var report := world_preview.get_excavation_preview_report(cell) var gate_message := _excavation_gate_message() if not gate_message.is_empty(): _set_status("掘削不可: %s%s" % [gate_message, _excavation_cost_and_time(report)], false) return if not bool(report.get(&"ok", false)): _set_status(_excavation_failure_message(report), false) return if _runtime == null or not _runtime.has_method(&"submit_command"): _set_status("Simulation Runtimeへ接続できません。", false) return var spatial_position := Vector2i(cell.x, cell.y) if _pending_excavation_commands.values().has(spatial_position): _set_status("このマスの掘削指示はすでに受理されています。", false) return var idempotency_key := _excavation_idempotency_key(cell) var result: Variant = _submit_ui_command(&"start_excavation", {&"cell": Vector3i(cell.x, cell.y, world_preview.layer_mode)}, &"player", &"", 50, -1, idempotency_key ) if result == null or not result.has_method(&"is_success"): _set_status("掘削指示を送信できませんでした。", false) return if not bool(result.call(&"is_success")): _set_status(_excavation_command_failure_message(result), false) return if result.details is Dictionary and result.details.has(&"job_id"): _select_excavation_job(StringName(result.details.get(&"job_id", &""))) _set_status("掘削を開始しました(%d, %d / FRONT・BACK)。" % [cell.x, cell.y], true) else: _pending_excavation_commands[int(result.command_id)] = spatial_position _set_status("掘削指示を受理しました(%d, %d / FRONT・BACK)。" % [cell.x, cell.y], true) _refresh_dashboard() func _excavation_idempotency_key(cell: Vector3i) -> StringName: # One click gets one stable retry identity. A later player retry receives a # new sequence so a previously cached validation failure cannot block it. var sequence := _excavation_sequence _excavation_sequence += 1 return StringName( "ui.excavation.%d.%d.%08d" % [cell.x, cell.y, sequence] ) func _on_facility_selected(instance_id: StringName) -> void: _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = instance_id _selected_entity_kind = &"facility" _refresh_selected_entity() func _on_legacy_stair_selected(stair_id: StringName) -> void: _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = stair_id _selected_entity_kind = &"legacy_stairs" world_preview.selected_entity_id = stair_id _refresh_selected_entity() func _on_construction_selected(job_id: StringName) -> void: _set_ledger_panel_open(false) _set_supply_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = job_id _selected_entity_kind = &"construction" _refresh_selected_entity() 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() if weather_label != null and is_instance_valid(_runtime) and _runtime.has_method("get_weather_report"): var weather_report: Dictionary = _runtime.get_weather_report() var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"} weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —") var next_change: Dictionary = weather_report.get(&"next_change", {}) var forecast := "次: 予定なし" if not next_change.is_empty(): var change_minute := posmod(int(next_change.get(&"minute", 0)), 1_440) forecast = "次: %s %02d:%02d" % [weather_names.get(StringName(next_change.get(&"kind", &"")), "天候 —"), change_minute / 60, change_minute % 60] var effect := String(weather_report.get(&"effect_text", "")) weather_label.tooltip_text = "%s\n毎朝6時にその日の天候が決まります。" % effect if not effect.is_empty() else weather_label.tooltip_text if weather_forecast_label != null: weather_forecast_label.text = forecast weather_forecast_label.tooltip_text = "%s\n%s" % [forecast, effect] var guests := _call_array(_service(&"guest_service"), &"get_guests") var queued_guests := 0 var served_guests := 0 for guest: Variant in guests: if guest == null: continue if int(guest.state) == 4: queued_guests += 1 elif int(guest.state) == 5: served_guests += 1 var staff_members := _call_array(_service(&"staff_service"), &"get_staff_members") var available_staff := 0 var working_staff := 0 for staff: Variant in staff_members: if staff == null: continue if int(staff.state) == 2: available_staff += 1 elif int(staff.state) in [3, 4]: working_staff += 1 var event_service: Variant = _service(&"event_service") var events := _call_array(event_service, &"get_instances") var active_events := 0 for event_instance: Variant in events: if event_instance != null and int(event_instance.state) in [0, 1, 2, 7]: active_events += 1 var active_jobs := 0 for job: Variant in _call_array(_service(&"construction_service"), &"get_jobs"): if job != null and int(job.state) not in [2, 3]: active_jobs += 1 var heat_text := "—" var heat_value := 0 var authority: Variant = _service(&"authority_service") if authority != null and authority.has_method(&"get_authority_state"): var heat_state: Variant = authority.call(&"get_authority_state") if heat_state != null: heat_value = int(heat_state.heat) heat_text = "%d / 100" % heat_value var foreground_text := "なし" if event_service != null and event_service.has_method(&"get_foreground_instance"): var foreground: Variant = event_service.call(&"get_foreground_instance") if foreground != null: foreground_text = String(foreground.resolved_event_id) _refresh_foreground_event(event_service) var defense_summary: Dictionary = {} var defense: Variant = _service(&"defense_service") if defense != null and defense.has_method(&"get_threat_summary"): defense_summary = defense.call(&"get_threat_summary") var threat_count := int(defense_summary.get(&"total", 0)) var defense_text := "防衛 準備中 %d / 戦闘中 %d / 次波待ち %d" % [ int(defense_summary.get(&"preparing", 0)), int(defense_summary.get(&"active", 0)), int(defense_summary.get(&"between_waves", 0))] var operations_text := _operations_dashboard_text() dashboard_summary_label.text = ( "客 %d人(待機 %d / 利用 %d)\n" % [guests.size(), queued_guests, served_guests] + "従業員 %d人(待機 %d / 作業 %d)\n" % [staff_members.size(), available_staff, working_staff] + "イベント %d件 前景: %s\n" % [active_events, foreground_text] + "警戒度 %s  建設中 %d件" % [heat_text, active_jobs] # Keep a stable row so threat changes do not move staff drag targets. + "\n" + defense_text + operations_text ) if guest_count_label != null: guest_count_label.text = "来客 %d人" % guests.size() if lodging_count_label != null: var lodging_guests := 0 for guest: Variant in guests: if guest != null and int(guest.planned_exit_at) >= GameClock.absolute_minute + 360: lodging_guests += 1 lodging_count_label.text = "宿泊 %d人" % lodging_guests if alert_count_label != null: alert_count_label.text = "警戒 %d" % heat_value + (" / 襲撃 %d" % threat_count if threat_count > 0 else "") alert_count_label.tooltip_text = (defense_text + "\n防衛参加中の従業員は配属変更できません。") if threat_count > 0 else "進行中の襲撃はありません。" alert_count_label.add_theme_color_override( "font_color", Color("ffb498") if heat_value >= 60 or threat_count > 0 else Color("ebca84") if heat_value >= 30 else Color("c7d3a8") ) _refresh_staff_candidates() _refresh_selected_entity() _update_pause_and_speed() if opening_guide != null: opening_guide.refresh() if operating_ledger_panel != null and _ledger_panel_open: operating_ledger_panel.refresh() func _refresh_foreground_event(event_service: Variant) -> void: if foreground_event_panel == null \ or foreground_event_label == null \ or foreground_event_choices == null: return for child: Node in foreground_event_choices.get_children(): foreground_event_choices.remove_child(child) child.queue_free() var foreground: Variant = null if event_service != null and event_service.has_method(&"get_foreground_instance"): foreground = event_service.call(&"get_foreground_instance") if foreground == null or int(foreground.state) != 2: 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) var actors: Array[String] = [] for actor_value: Variant in foreground.actor_ids: actors.append(String(actor_value)) foreground_event_label.text = ( "%s\n関係者: %s" % [ String(foreground.resolved_event_id), "・".join(actors) if not actors.is_empty() else "なし", ] ) var definition: Variant = null if event_service.has_method(&"get_definition"): definition = event_service.call(&"get_definition", foreground.resolved_event_id) var choices_value: Variant = _definition_value(definition, &"choices", []) if not choices_value is Array: return for choice: Variant in choices_value: if choice == null: continue var choice_id := StringName(_definition_value(choice, &"choice_id", &"")) if choice_id.is_empty(): continue var choice_button := Button.new() choice_button.text = _event_choice_button_text(choice) choice_button.custom_minimum_size.y = 34 _apply_button_style(choice_button) choice_button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART choice_button.pressed.connect( _submit_foreground_event_choice.bind( foreground.event_instance_id, foreground.choice_instance_id, choice_id ) ) foreground_event_choices.add_child(choice_button) func _event_choice_button_text(choice: Variant) -> String: var choice_id := StringName(_definition_value(choice, &"choice_id", &"")) var text_key := StringName(_definition_value(choice, &"text_key", choice_id)) var title := String(EVENT_CHOICE_NAMES.get(text_key, String(choice_id))) var cost_value: Variant = _definition_value(choice, &"cost", {}) if not cost_value is Dictionary or cost_value.is_empty(): return title var cost_parts: Array[String] = [] var cost_keys: Array = cost_value.keys() cost_keys.sort_custom(func(left: Variant, right: Variant) -> bool: return String(left) < String(right) ) for raw_key: Variant in cost_keys: cost_parts.append("%s %d" % [String(raw_key), int(cost_value[raw_key])]) return "%s(%s)" % [title, " / ".join(cost_parts)] func _submit_foreground_event_choice( event_instance_id: StringName, choice_instance_id: StringName, choice_id: StringName ) -> void: if _runtime == null or not _runtime.has_method(&"submit_command"): _set_status("イベント選択を送信できません。", false) return var idempotency_key := StringName( "ui.event.choice.%s.%s" % [String(choice_instance_id), String(choice_id)] ) var result: Variant = _submit_ui_command(&"submit_event_choice", { &"event_instance_id": event_instance_id, &"choice_instance_id": choice_instance_id, &"choice_id": choice_id, }, &"player", event_instance_id, 100, -1, idempotency_key ) var succeeded: bool = result != null \ and result.has_method(&"is_success") \ and bool(result.call(&"is_success")) _set_status( "イベントの選択を確定しました。" if succeeded \ else _command_failure_message(result) if result != null else "イベント選択に失敗しました。", succeeded ) _refresh_dashboard() _update_pause_and_speed() func _operations_dashboard_text() -> String: if _runtime == null or not _runtime.has_method(&"get_operations_report"): return "" var report_value: Variant = _runtime.call(&"get_operations_dashboard_report") if _runtime.has_method(&"get_operations_dashboard_report") else _runtime.call(&"get_operations_report") if not report_value is Dictionary: return "" var report: Dictionary = report_value if not bool(report.get(&"enabled", false)): return "\n運営日次処理 未開始" var coordinator_value: Variant = report.get(&"coordinator", {}) var coordinator: Dictionary = coordinator_value if coordinator_value is Dictionary else {} var terminal := StringName(coordinator.get(&"terminal_state", &"RUNNING")) var state_text := "営業中" if terminal == &"BANKRUPT": state_text = "ゲーム終了" elif bool(report.get(&"runtime_failure_latched", false)): state_text = "処理失敗・再試行待ち" elif bool(report.get(&"tick_blocked", false)): state_text = "確認待ち" var day_profit_text := "未確定" var summaries_value: Variant = coordinator.get(&"day_summaries", []) if summaries_value is Array and not summaries_value.is_empty(): var latest_value: Variant = summaries_value.back() if latest_value is Dictionary: day_profit_text = "G %+d" % int(latest_value.get(&"net_profit", 0)) var old_financial_history: bool = summaries_value is Array and not summaries_value.is_empty() and int(summaries_value.back().get(&"day", 0)) <= int(coordinator.get(&"legacy_history_last_day", 0)) return ( "\n行動点 %d / 6  未払経費 G %d\n" % [ int(report.get(&"personal_action_points", 0)), int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0))), ] + "運営状態 %s  前日損益 %s" % [state_text, day_profit_text] + "\n%s %d / 3回" % ["旧版の連続未払い" if old_financial_history else "連続赤字", int(coordinator.get(&"consecutive_insolvency_count", 0))] ) func _refresh_selected_entity() -> void: related_facility_picker.visible = false selected_elevator_controls.visible = false selected_detail_label.tooltip_text = "" if _selected_entity_id.is_empty(): _move_selected_detail_first(false) selected_entity_icon.texture = null selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設" selected_detail_label.text = ( "地下のマスにカーソルを合わせると、費用と所要時間を確認できます。" if _excavation_mode else "地図上の施設をクリックすると、営業状態を確認できます。" ) return if _selected_entity_kind == &"construction": _move_selected_detail_first(false) _refresh_selected_construction() return if _selected_entity_kind == &"excavation": _move_selected_detail_first(false) _refresh_selected_excavation() return if _refresh_selected_stairs(): return var shaft := _selected_elevator_report() if not shaft.is_empty(): _refresh_selected_elevator(shaft) return _move_selected_detail_first(false) var facility_service: Variant = _service(&"facility_service") if facility_service == null or not facility_service.has_method(&"get_instance"): selected_detail_label.text = "施設サービスを利用できません。" return var facility: Variant = facility_service.call(&"get_instance", _selected_entity_id) if facility == null: var construction_facility := _construction_facility(_selected_entity_id) if construction_facility.is_empty(): _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" selected_heading_label.text = "選択中の施設" selected_detail_label.text = "選択していた施設は現在の世界に存在しません。" selected_entity_icon.texture = null return var construction_definition_id := StringName(construction_facility.get(&"definition_id", &"")) var infrastructure_definition: Variant = _facility_definition(construction_definition_id) selected_heading_label.text = String(_definition_value(infrastructure_definition, &"display_name", construction_definition_id)) selected_entity_icon.texture = visual_theme.facility_icon(construction_definition_id) if visual_theme != null else null selected_detail_label.text = "設備完成\n" + _facility_upkeep_text(_selected_entity_id) return var definition: Variant = _facility_definition(facility.definition_id) selected_entity_icon.texture = visual_theme.facility_icon(facility.definition_id) if visual_theme != null else null related_facility_picker.clear() related_facility_picker.add_item("関連施設:指定なし") related_facility_picker.set_item_metadata(0, &"") for related: FacilityInstance in _runtime.facility_service.get_instances(): if related.instance_id == facility.instance_id: continue var related_definition: Variant = _facility_definition(related.definition_id) related_facility_picker.add_item("関連施設:%s (%d, %d)" % [String(_definition_value(related_definition, &"display_name", related.definition_id)), related.origin.x, related.origin.y]) var index := related_facility_picker.item_count - 1 related_facility_picker.set_item_metadata(index, related.instance_id) if related.instance_id == facility.related_facility_id: related_facility_picker.select(index) related_facility_picker.visible = true var display_name := String(_definition_value(definition, &"display_name", facility.definition_id)) var state_index := clampi(int(facility.state), 0, FACILITY_STATE_NAMES.size() - 1) var reasons := _facility_reasons(facility.state_reasons) var queue_count: int = facility.queue_entries.size() var service_count: int = facility.active_services.size() var staff_count: int = facility.active_staff_ids.size() var occupied_slots: int = service_count var related_name := "なし" if not facility.related_facility_id.is_empty(): var related: FacilityInstance = _runtime.facility_service.get_instance(facility.related_facility_id) related_name = "撤去済み" if related == null else "%s (%d, %d)" % [String(_definition_value(_facility_definition(related.definition_id), &"display_name", related.definition_id)), related.origin.x, related.origin.y] selected_heading_label.text = display_name selected_detail_label.text = ( "状態 %s%s\n" % [FACILITY_STATE_NAMES[state_index], "(%s)" % reasons if not reasons.is_empty() else ""] + "待機列 %d / %d  サービス %d / %d\n" % [queue_count, int(facility.queue_capacity), occupied_slots, int(facility.capacity)] + "稼働区分 %s\n顧客同時利用 %d / %s\n" % [["未稼働", "稼働", "休止"][facility.operation_state], facility.customer_count(), str(facility.customer_capacity) if facility.customer_capacity >= 0 else "未設定"] + "関連施設 %s(%s)\n従業員上限 %d\n" % [related_name, "稼働条件成立" if facility.related_facility_operating else "未成立", facility.staff_max] + "配置人員 %d / 必要 %d  耐久 %d%%\n" % [staff_count, int(facility.staff_min), int(facility.durability) / 100] + _facility_upkeep_text(_selected_entity_id) + "基本料金 G %d  所要 %d分\n" % [int(facility.base_price), int(facility.base_service_minutes)] + "提供サービス %s\n" % {&"": "未設定", &"appetite": "食欲", &"relaxation": "リラックス欲", &"sexual": "性欲", &"gambling": "ギャンブル欲"}.get(facility.service_attribute, "未設定") + "座標 (%d, %d) %s" % [facility.origin.x, facility.origin.y, "FRONT" if facility.origin.z == 0 else "BACK"] ) func _facility_upkeep_text(facility_id: StringName) -> String: if _runtime == null or not _runtime.has_method(&"get_facility_upkeep_report"): return "" var report: Dictionary = _runtime.call(&"get_facility_upkeep_report", facility_id) var pending := float(report[&"unsettled_units"]) / float(report[&"gold_denominator"]) return "維持費 %dG / 日\n精算前 約%.2fG 未払い %dG\n閉店・休止中も計上\n完成から撤去までの時間で按分\n" % [int(report[&"daily_rate"]), pending, int(report[&"arrears"])] 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 = _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) _refresh_selected_entity() func _refresh_selected_stairs() -> bool: var from_cell := Vector3i.ZERO var to_cell := Vector3i.ZERO var definition_id: StringName = &"" var legacy := _selected_entity_kind == &"legacy_stairs" var legacy_access: StringName = &"" if legacy: for stair: Dictionary in world_preview.get_legacy_stair_records(): if StringName(stair[&"id"]) == _selected_entity_id: from_cell = stair[&"from"] legacy_access = StringName(stair.get(&"access_id", &"")) to_cell = stair[&"to"] definition_id = &"guest_stairs" if from_cell.z == 0 else &"service_stairs" break else: var facility := _construction_facility(_selected_entity_id) definition_id = StringName(facility.get(&"definition_id", &"")) if definition_id not in [&"guest_stairs", &"service_stairs"]: return false var origin: Variant = facility.get(&"origin", Vector3i.ZERO) if origin is Array and origin.size() == 3: origin = Vector3i(int(origin[0]), int(origin[1]), int(origin[2])) to_cell = origin from_cell = to_cell+Vector3i(0,-1,0) if definition_id.is_empty(): return false _move_selected_detail_first(true) var staff_only := legacy_access == &"staff" if legacy else definition_id == &"service_stairs" selected_heading_label.text = "既設の階段" if legacy else ("業務用階段" if staff_only else "客用階段") selected_entity_icon.texture = visual_theme.facility_icon(definition_id) if visual_theme != null else null selected_detail_label.text = ( "接続階 %s ↔ %s\n" % [_elevator_floor_name(mini(from_cell.y,to_cell.y)), _elevator_floor_name(maxi(from_cell.y,to_cell.y))] + "利用者 %s\n" % ("従業員専用・物資運搬可" if staff_only else "客・従業員用") + "階段で昇り降りします。魔力は不要です。\n" + "座標 (%d, %d) %s" % [to_cell.x,maxi(from_cell.y,to_cell.y),"BACK" if to_cell.z == 1 else "FRONT"] ) return true func _selected_elevator_report() -> Dictionary: if _selected_entity_kind != &"facility" or _selected_entity_id.is_empty(): return {} var transport: Variant = _service(&"elevators") if transport == null or not transport.has_method(&"get_report"): return {} for shaft: Dictionary in transport.call(&"get_report").get(&"shafts", []): if (shaft.get(&"module_ids", []) as Array).has(_selected_entity_id): return shaft return {} func _refresh_selected_elevator(shaft: Dictionary) -> void: _move_selected_detail_first(true) var construction_facility := _construction_facility(_selected_entity_id) var definition_id := StringName(construction_facility.get(&"definition_id", &"guest_elevator")) var definition: Variant = _facility_definition(definition_id) selected_heading_label.text = String(_definition_value(definition, &"display_name", "昇降機")) selected_entity_icon.texture = visual_theme.facility_icon(definition_id) if visual_theme != null else null var powered := bool(shaft.get(&"powered", true)) var operator_open := bool(shaft.get(&"operator_open", true)) var state_text := String(ELEVATOR_PHASE_NAMES.get(StringName(shaft.get(&"phase", &"IDLE")), "待機")) if not operator_open: state_text = "手動停止" elif not powered: state_text = "魔力供給なし" var floor_index := int(shaft.get(&"current_floor", 0)) var target_index := int(shaft.get(&"boarding_floor", floor_index) if StringName(shaft.get(&"phase", &"IDLE")) == &"PICKUP" else shaft.get(&"target_floor", floor_index)) var stops: Array = shaft.get(&"stops", []) var stop_text := "—" if not stops.is_empty(): stop_text = "%s 〜 %s" % [_elevator_floor_name(stops.front().y), _elevator_floor_name(stops.back().y)] selected_detail_label.text = ( "状態 %s\n" % state_text + "現在階 %s  目的階 %s\n" % [_elevator_floor_name(floor_index), _elevator_floor_name(target_index)] + "乗客 %d / %d人  待機 %d人\n" % [int(shaft.get(&"passenger_count", 0)), int(shaft.get(&"capacity", 0)), int(shaft.get(&"waiting_count", 0))] + "停止階 %s\n" % stop_text + "魔力 %s  %s" % ["供給中" if powered else "不足", "客・従業員用" if definition_id == &"guest_elevator" else "従業員・物資用"] ) selected_detail_label.text += _elevator_wait_text(StringName(shaft.get(&"shaft_id", &""))) selected_elevator_controls.visible = true elevator_operation_button.disabled = _pending_elevator_commands.values().has(shaft.get(&"shaft_id", &"")) elevator_operation_button.text = "指示を受理済み…" if elevator_operation_button.disabled else ("昇降機を停止" if operator_open else "運行を再開") elevator_operation_hint.text = "停止すると最寄りの階で降車し、待機中の呼び出しを解除します。" if operator_open else ("再開すると新しい呼び出しを受け付けます。" if powered else "魔力が供給されると運行を再開できます。") func _elevator_wait_text(shaft_id: StringName) -> String: selected_detail_label.tooltip_text = "保持している直近256件の出来事のうち、過去60分の乗車を集計。現在の最長待ちは、いま並んでいる人の待ち時間です。" if _runtime == null or not _runtime.has_method(&"get_elevator_wait_statistics"): return "\n最近の乗車記録 計測待ち" var statistics: Dictionary = _runtime.call(&"get_elevator_wait_statistics", shaft_id) var samples := int(statistics.get(&"sample_count", 0)) var text := "\n最近の乗車記録 %d人" % samples if samples == 0: text += "(計測待ち)" else: text += "\n平均待ち %.1f分 / 95%% %.1f分" % [float(statistics.get(&"mean_wait_minutes",0)),float(statistics.get(&"p95_wait_minutes",0))] return text+"\n現在の最長待ち %d分" % int(statistics.get(&"oldest_wait_minutes",0)) func _move_selected_detail_first(first: bool) -> void: var column := selected_heading_label.get_parent() var selected_row := selected_detail_label.get_parent() # The transport action fits without scrolling; other management views keep # their existing staff-first layout. var desired_index := 0 if first else column.get_child_count()-4 if selected_heading_label.get_index() == desired_index: return column.move_child(selected_heading_label, 0 if first else -1) column.move_child(selected_row, 1 if first else -1) column.move_child(selected_elevator_controls, 2 if first else -1) column.move_child(related_facility_picker, 3 if first else -1) func _elevator_floor_name(floor_index: int) -> String: return "%dF" % (floor_index+1) if floor_index >= 0 else "B%d" % -floor_index func _on_elevator_operation_pressed() -> void: var shaft := _selected_elevator_report() if shaft.is_empty() or _runtime == null: return var shaft_id := StringName(shaft[&"shaft_id"]) if _pending_elevator_commands.values().has(shaft_id): return var key := StringName("ui.elevator.%08d" % _elevator_sequence) _elevator_sequence += 1 var open_value := not bool(shaft.get(&"operator_open", true)) 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 if not result.details.has(&"shaft_id"): _pending_elevator_commands[int(result.command_id)] = shaft_id _set_status("昇降機の運行指示を受理しました。", true) else: var message := "昇降機を停止しました。" if open_value: message = "昇降機の運行を再開しました。" if bool(shaft.get(&"powered", true)) else "運行を許可しました。魔力の供給待ちです。" _set_status(message, true) _refresh_selected_entity() func _synchronize_elevator_pending() -> void: var queue: Variant = _service(&"command_queue") if queue == null: return if queue.pending_count() == 0: _pending_elevator_commands.clear() return _pending_elevator_commands.clear() for command: Dictionary in queue.create_snapshot().get("pending", []): if StringName(command.get("command_type", &"")) == &"set_elevator_operation": _pending_elevator_commands[int(command.get("command_id", 0))] = StringName((command.get("payload", {}) as Dictionary).get(&"shaft_id", &"")) func _refresh_selected_construction() -> void: var construction: Variant = _service(&"construction_service") if construction == null or not construction.has_method(&"get_job"): selected_detail_label.text = "建設サービスを利用できません。" return var job: Variant = construction.call(&"get_job", _selected_entity_id) if job == null: _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" selected_heading_label.text = "選択中の施設" selected_detail_label.text = "選択していた工事は現在の世界に存在しません。" selected_entity_icon.texture = null return var definition: Variant = _facility_definition(job.definition_id) selected_entity_icon.texture = visual_theme.facility_icon(job.definition_id) if visual_theme != null else null var display_name := String(_definition_value(definition, &"display_name", job.definition_id)) var state_names := ["計画済み", "建設中", "完成", "中止", "停止中"] var job_state := clampi(int(job.state), 0, state_names.size() - 1) selected_heading_label.text = "%s(工事)" % display_name selected_detail_label.text = ( "状態 %s\n" % state_names[job_state] + "進捗 %d%%  予定 %d分\n" % [int(job.progress) / 100, int(job.build_minutes)] + "座標 (%d, %d) %s\n" % [job.origin.x, job.origin.y, "FRONT" if int(job.layer) == 0 else "BACK"] + ("停止理由 %s" % String(job.state_reason) if not String(job.state_reason).is_empty() else "建設はシミュレーション時間とともに進みます。") ) func _select_excavation_job(job_id: StringName) -> void: if job_id.is_empty(): return _selected_entity_id = job_id _selected_entity_kind = &"excavation" world_preview.selected_entity_id = &"" world_preview.clear_hovered_cell() _refresh_selected_excavation() func _refresh_selected_excavation() -> void: selected_entity_icon.texture = visual_theme.control_icon(&"excavate") if visual_theme != null else null var excavation: Variant = _service(&"excavation_service") if excavation == null or not excavation.has_method(&"get_job"): selected_detail_label.text = "掘削サービスを利用できません。" return var job_value: Variant = excavation.call(&"get_job", _selected_entity_id) if not job_value is Dictionary or job_value.is_empty(): _selected_entity_id = &"" _selected_entity_kind = &"" selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設" selected_detail_label.text = "選択していた掘削作業は現在の世界に存在しません。" return var job: Dictionary = job_value var state := StringName(job.get(&"state", &"")) var state_text := "掘削中" if state == &"PAUSED": state_text = "一時停止" elif state == &"COMPLETED": state_text = "完了" var position: Vector2i = job.get(&"position", Vector2i.ZERO) var cost_value: Variant = job.get(&"cost", {}) var cost: Dictionary = cost_value if cost_value is Dictionary else {} var guidance := "両レイヤーが開通しました。" if state == &"ACTIVE": guidance = "掘削はシミュレーション時間とともに進みます。" elif state == &"PAUSED": guidance = "停止理由 %s" % String(job.get(&"state_reason", "")) selected_heading_label.text = "地下掘削(%d, %d)" % [position.x, position.y] selected_detail_label.text = ( "状態 %s  進捗 %d%%\n" % [state_text, clampi(int(job.get(&"progress", 0)) / 100, 0, 100)] + "所要 %d分  費用 G %d / 資材 %d\n" % [ int(job.get(&"build_minutes", 0)), int(cost.get(&"gold", cost.get("gold", 0))), int(cost.get(&"materials", cost.get("materials", 0))), ] + "対象 FRONT / 表層・BACK / 裏層\n" + guidance ) func _preview_failure_message(report: Dictionary) -> String: var codes_value: Variant = report.get(&"error_codes", []) if codes_value is Array and not codes_value.is_empty(): var code := String(codes_value[0]) if PREVIEW_REASON_NAMES.has(code): return "配置不可: %s" % PREVIEW_REASON_NAMES[code] var code_reasons: Variant = report.get(&"reasons", []) if code_reasons is Array and not code_reasons.is_empty(): var reason: Variant = code_reasons[0] for candidate: Variant in code_reasons: if candidate is Dictionary and String(candidate.get(&"code", "")) == code: reason = candidate break if reason is Dictionary: var message := String(reason.get(&"message", "")) if not message.is_empty(): return "配置不可: %s" % message var reasons_value: Variant = report.get(&"reasons", []) if reasons_value is Array and not reasons_value.is_empty() and reasons_value[0] is Dictionary: return "配置不可: %s" % String(reasons_value[0].get(&"message", "条件を満たしていません")) return "このマスには配置できません。" func _excavation_failure_message(report: Dictionary) -> String: var errors_value: Variant = report.get(&"errors", []) var reason := "条件を満たしていません" if errors_value is Array and not errors_value.is_empty(): var code := StringName(errors_value[0]) reason = String(EXCAVATION_REASON_NAMES.get(code, String(code))) if code == &"INSUFFICIENT_RESOURCES": var missing_value: Variant = report.get(&"missing_resources", {}) if missing_value is Dictionary and not missing_value.is_empty(): var missing: Dictionary = missing_value reason += "(不足: G %d / 資材 %d)" % [ int(missing.get(&"gold", missing.get("gold", 0))), int(missing.get(&"materials", missing.get("materials", 0))), ] return "掘削不可: %s%s" % [reason, _excavation_cost_and_time(report)] func _excavation_cost_and_time(report: Dictionary) -> String: var cost_value: Variant = report.get(&"cost", {}) var cost: Dictionary = cost_value if cost_value is Dictionary else {} var minutes := int(report.get(&"build_minutes", 0)) if cost.is_empty() and minutes <= 0: return "" return "(費用 G %d / 資材 %d・所要 %d分)" % [ int(cost.get(&"gold", cost.get("gold", 0))), int(cost.get(&"materials", cost.get("materials", 0))), minutes, ] func _excavation_gate_message() -> String: var progression: Variant = _service(&"progression_service") if progression == null: return "世界樹ランクを確認できません" var current_rank := int(_definition_value(progression, &"current_rank", 1)) if current_rank < 2: return "世界樹ランク2で地下掘削が解禁されます(現在ランク%d)" % current_rank if _runtime == null or not _runtime.has_method(&"get_operations_report"): return "運営状態を確認できません" var report_value: Variant = _runtime.call(&"get_operations_report") if not report_value is Dictionary: return "運営状態を確認できません" var report: Dictionary = report_value if not bool(report.get(&"enabled", false)): return "本番運営が始まるまで掘削できません" var coordinator_value: Variant = report.get(&"coordinator", {}) var coordinator: Dictionary = coordinator_value if coordinator_value is Dictionary else {} if StringName(coordinator.get(&"terminal_state", &"RUNNING")) == &"BANKRUPT": return "資金破綻により新しい掘削を開始できません" if bool(coordinator.get(&"external_quarantined", false)): return "運営データの復旧が必要なため掘削を開始できません" var arrears := int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0))) if arrears > 0: return "未払経費 G %dを清算するまで掘削できません" % arrears return "" func _excavation_command_failure_message(result: Variant) -> String: var details: Variant = result.details if details is Dictionary: var report_value: Variant = details.get(&"report", null) if report_value is Dictionary: return _excavation_failure_message(report_value) var reason := StringName(details.get(&"reason", &"")) match reason: &"ARREARS_REMAIN", &"UNPAID_EXPENSES": return "掘削不可: 未払経費 G %dを清算してください" % int(details.get(&"arrears_total", 0)) &"BANKRUPT": return "掘削不可: 資金破綻により新しい工事は開始できません" &"EXTERNAL_ROLLBACK_FAILED": return "掘削不可: 運営データの復旧が必要です" if details.has(&"required_rank"): return "掘削不可: 世界樹ランク%dが必要です(現在ランク%d)" % [ int(details.get(&"required_rank", 2)), int(details.get(&"current_rank", 1)), ] if int(result.code) == int(CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY): return "この掘削指示はすでに受理されています。" return _command_failure_message(result) func _command_failure_message(result: Variant) -> String: var message := String(result.message) var details: Variant = result.details if details is Dictionary: var report_value: Variant = details.get(&"placement_report", null) if report_value is Dictionary: return _preview_failure_message(report_value) return "操作に失敗しました: %s" % (message if not message.is_empty() else "不明なエラー") 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 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 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") ): _set_status("読込サービスへ接続できません。", false) return var result: Variant = ( _runtime.call(&"load_with_recovery", save_path) if not strict and _runtime.has_method(&"load_with_recovery") 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) _set_status("手動セーブを読み込みました。", true) else: _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: var panel := guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel if panel != null: selection[&"guest_id"] = String(panel.selected_guest_id) selection[&"guest_open"] = guest_observer.visible if defense_manager != null: var panel := defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel if panel != null: selection[&"defense_id"] = String(panel.selected_raid_id) selection[&"defense_open"] = defense_manager.visible return selection func _on_checkpoint_presentation_requested(presentation: Dictionary) -> void: presentation[&"interface_selection"] = _capture_interface_selection() func _on_save_operation_completed(result: Dictionary) -> void: if not bool(result.get(&"ok", false)) or StringName(result.get(&"operation", &"")) not in [&"load", &"recovery", &"recovery_candidate"]: return _apply_loaded_interface(result) func _apply_loaded_interface(result: Dictionary) -> void: if _audio_feedback != null: _audio_feedback.begin_loaded_epoch(_current_audio_foreground()) _pending_build_commands.clear() _pending_demolition_commands.clear() _set_demolition_mode(false, false) _pending_excavation_commands.clear() _pending_hire_commands.clear() _pending_elevator_commands.clear() _synchronize_ui_command_state() if staff_management_panel != null: staff_management_panel.refresh() if supply_management_panel != null: supply_management_panel.refresh() if business_management_panel != null: business_management_panel.refresh() _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" world_preview.set_runtime(_runtime) _set_layer(GameState.active_layer) _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() func _valid_interface_selection_version(value: Variant) -> bool: # JSON numbers may be decoded as floats; booleans are never versions. return typeof(value) in [TYPE_INT, TYPE_FLOAT] and value == 1 func _restore_interface_selection(manifest: Variant) -> void: # Presentation metadata is optional. It must never invalidate gameplay, # and recovery must use the manifest of the actually loaded candidate. if not manifest is Dictionary: return var selection: Variant = manifest.get("interface_selection", {}) if not selection is Dictionary or not _valid_interface_selection_version(selection.get("version", 0)): return var kind_value: Variant = selection.get("kind", "") var id_value: Variant = selection.get("id", "") if not kind_value is String or not id_value is String or id_value.is_empty() or id_value.length() > 256: return var kind := StringName(kind_value) var entity_id := StringName(id_value) var exists := false match kind: &"facility": exists = _service(&"facility_service").get_instance(entity_id) != null or not _construction_facility(entity_id).is_empty() &"construction": exists = _service(&"construction_service").get_job(entity_id) != null &"excavation": exists = not _service(&"excavation_service").get_job(entity_id).is_empty() &"legacy_stairs": for stair: Dictionary in world_preview.get_legacy_stair_records(): if StringName(stair.get(&"id", &"")) == entity_id: exists = true break if not exists: return _selected_entity_kind = kind _selected_entity_id = entity_id world_preview.selected_entity_id = entity_id if kind != &"excavation" else &"" _set_management_panel_open(true) func _restore_actor_interface_selection(manifest: Variant) -> void: # Only successful gameplay loads reach here. Reset stale live selections, # 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("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.clear_selection() if not manifest is Dictionary: return var selection: Variant = manifest.get("interface_selection", {}) if not selection is Dictionary or not _valid_interface_selection_version(selection.get("version", 0)): return var staff_id: Variant = selection.get("staff_id", "") if staff_id is String and not staff_id.is_empty() and staff_id.length() <= 256 and _service(&"staff_service").has_staff(StringName(staff_id)): staff_management_panel.call("select_staff", StringName(staff_id)) if selection.get("staff_open", false) is bool and selection.get("staff_open", false): _activate_management_mode(&"staff") var guest_id: Variant = selection.get("guest_id", "") if guest_id is String and not guest_id.is_empty() and guest_id.length() <= 256 and _service(&"guest_service").has_guest(StringName(guest_id)): var panel := _ensure_guest_observer() panel.selected_guest_id = StringName(guest_id) panel.refresh() if selection.get("guest_open", false) is bool and selection.get("guest_open", false): _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", "保存データがありません"))) return message if not message.is_empty() else "保存データがありません" return "サービスから応答がありません" func _synchronize_ui_command_state() -> void: if _runtime == null: return var queue: Variant = _runtime.get(&"command_queue") if queue == null: return if queue.has_method(&"get_accepted_command_metadata"): var metadata_value: Variant = queue.call(&"get_accepted_command_metadata") if metadata_value is Array: for raw_metadata: Variant in metadata_value: if not raw_metadata is Dictionary: continue _advance_ui_sequence_from_key(String(raw_metadata.get("idempotency_key", ""))) if not queue.has_method(&"create_snapshot"): return var snapshot_value: Variant = queue.call(&"create_snapshot") if not snapshot_value is Dictionary: return var pending_value: Variant = snapshot_value.get("pending", []) if not pending_value is Array: return for raw_pending: Variant in pending_value: if not raw_pending is Dictionary: continue var command_id := int(raw_pending.get("command_id", 0)) var command_type := StringName(raw_pending.get("command_type", &"")) var payload_value: Variant = raw_pending.get("payload", {}) var payload: Dictionary = payload_value if payload_value is Dictionary else {} if command_id <= 0: continue match command_type: &"set_elevator_operation": _pending_elevator_commands[command_id] = StringName(payload.get(&"shaft_id", &"")) &"build_facility": var definition_id := StringName(payload.get(&"definition_id", payload.get("definition_id", &""))) if not definition_id.is_empty(): _pending_build_commands[command_id] = definition_id &"start_excavation": var cell_value: Variant = payload.get(&"cell", payload.get("cell", null)) if cell_value is Vector3i: _pending_excavation_commands[command_id] = Vector2i(cell_value.x, cell_value.y) &"hire_staff_candidate": var candidate_id := StringName(payload.get(&"candidate_id", payload.get("candidate_id", &""))) if not candidate_id.is_empty(): _pending_hire_commands[command_id] = candidate_id func _advance_ui_sequence_from_key(key: String) -> void: if key.is_empty(): return var parts := key.split(".") if parts.is_empty(): return var suffix := String(parts[parts.size() - 1]) if not suffix.is_valid_int(): return var next_sequence := int(suffix) + 1 if key.begins_with("ui.build."): _build_sequence = maxi(_build_sequence, next_sequence) elif key.begins_with("ui.excavation."): _excavation_sequence = maxi(_excavation_sequence, next_sequence) elif key.begins_with("ui.hire."): _hire_sequence = maxi(_hire_sequence, next_sequence) elif key.begins_with("ui.elevator."): _elevator_sequence = maxi(_elevator_sequence, next_sequence) var _dashboard_refresh_pending := false func _queue_dashboard_refresh() -> void: if _dashboard_refresh_pending: return _dashboard_refresh_pending = true _flush_dashboard_refresh.call_deferred() func _flush_dashboard_refresh() -> void: if not _dashboard_refresh_pending or not is_inside_tree(): return _dashboard_refresh_pending = false _refresh_dashboard() func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: _synchronize_elevator_pending() _queue_dashboard_refresh() _update_pause_and_speed() _refresh_mana_tooltip() var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {}) var summaries: Array = operation.get(&"day_summaries", []) if summaries.is_empty(): _last_presented_close_minute = 0 return var latest: Dictionary = summaries.back() var closed_at := int(latest.get(&"closed_at", 0)) if closed_at > _last_presented_close_minute: _last_presented_close_minute = closed_at operating_ledger_panel.select_latest_close() _activate_management_mode(&"ledger") var losses := int(latest.get(&"consecutive_insolvency_count", 0)) if int(latest[&"day"]) <= int(operation.get(&"legacy_history_last_day", 0)): _set_status("旧版で確定した決算を表示しています。", true, true) return _set_status("3回連続の赤字決算でゲーム終了です。帳簿を確認できます。" if String(latest.get(&"terminal_state", "")) == "BANKRUPT" else "第%d日 決算:利益 %+dG・連続赤字 %d回" % [int(latest[&"day"]), int(latest[&"net_profit"]), losses], true, true) else: _last_presented_close_minute = closed_at func _on_command_completed(result: Variant) -> void: if result == null: return if _pending_demolition_commands.has(int(result.command_id)): _pending_demolition_commands.erase(int(result.command_id)) _show_demolition_result(result) return if _pending_related_commands.has(int(result.command_id)): _pending_related_commands.erase(int(result.command_id)) _set_status("関連施設の設定を反映しました。" if result.is_success() else result.message, result.is_success()) _refresh_selected_entity() return if _pending_elevator_commands.has(int(result.command_id)): _pending_elevator_commands.erase(int(result.command_id)) _set_status("昇降機の運行設定を反映しました。" if result.is_success() else "昇降機の運行設定を反映できませんでした。", result.is_success()) _refresh_selected_entity() return if _pending_hire_commands.has(int(result.command_id)): var candidate_id := StringName(_pending_hire_commands[int(result.command_id)]) _pending_hire_commands.erase(int(result.command_id)) var display_name := String(candidate_id) for summary: Dictionary in _staff_candidate_summaries(): if StringName(summary.get(&"candidate_id", &"")) == candidate_id: display_name = String(summary.get(&"display_name", candidate_id)) break if bool(result.call(&"is_success")): _set_status("%sを雇用しました。" % display_name, true) else: _set_status(_hire_failure_message(result), false) _refresh_dashboard() return if _pending_excavation_commands.has(int(result.command_id)): var position: Vector2i = _pending_excavation_commands[int(result.command_id)] _pending_excavation_commands.erase(int(result.command_id)) if bool(result.call(&"is_success")): _select_excavation_job(StringName(result.details.get(&"job_id", &""))) _set_status("掘削を開始しました(%d, %d / FRONT・BACK)。" % [position.x, position.y], true) else: _set_status(_excavation_command_failure_message(result), false) return if not _pending_build_commands.has(int(result.command_id)): return var definition_id := StringName(_pending_build_commands[int(result.command_id)]) _pending_build_commands.erase(int(result.command_id)) if bool(result.call(&"is_success")): var definition: Variant = _facility_definition(definition_id) _set_status("%sの建設を開始しました。" % String(_definition_value(definition, &"display_name", definition_id)), true) else: _set_status(_command_failure_message(result), false) 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 if front_button != null: front_button.set_pressed_no_signal(normalized == 0) if back_button != null: back_button.set_pressed_no_signal(normalized == 1) for label: Label in [dashboard_summary_label, staff_candidate_summary_label, selected_detail_label, elevator_operation_hint]: if label != null: LayerSkin.register_text(label, normalized) # Styles are registered in the saved layer during construction. # Reapplying them before the initial container layout causes extra sizing. 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")) var background := get_node_or_null("InterfaceBackground") as ColorRect if background != null: background.color = Color("10191e") if normalized == 1 else _visual_color(&"window_background", Color("24170f")) EventBus.layer_changed.emit(normalized) func _toggle_pause() -> void: var event_service: Variant = _service(&"event_service") if event_service != null and event_service.has_method(&"get_foreground_instance"): var foreground: Variant = event_service.call(&"get_foreground_instance") if foreground != null and int(foreground.state) == 2: GameClock.set_paused(true) _set_status("先に要対応イベントの選択肢を決めてください。", false) _update_pause_and_speed() return if _runtime != null \ and _runtime.has_method(&"get_operations_report") \ and _runtime.has_method(&"acknowledge_operations_pause"): var report_value: Variant = _runtime.call(&"get_operations_report") if report_value is Dictionary: var report: Dictionary = report_value var coordinator_value: Variant = report.get(&"coordinator", {}) var coordinator: Dictionary = ( coordinator_value if coordinator_value is Dictionary else {} ) if StringName(coordinator.get(&"terminal_state", &"RUNNING")) == &"BANKRUPT": _set_status("資金破綻により運営は終了しています。", false) return if bool(report.get(&"enabled", false)) \ and int(report.get(&"deferred_runtime_minute", -1)) >= 0: var retry_value: Variant = _runtime.call(&"acknowledge_operations_pause") var retry_ok := retry_value is Dictionary and bool(retry_value.get(&"ok", false)) _set_status( "運営処理を再開しました。" if retry_ok \ else "運営処理を再開できません: %s" % _result_message(retry_value), retry_ok ) _refresh_dashboard() _update_pause_and_speed() return GameClock.toggle_pause() _update_pause_and_speed() func _set_speed(value: int) -> void: GameClock.set_speed(value) _update_pause_and_speed() func _update_pause_and_speed() -> void: var operations_deferred := false var operations_terminal := false var event_waiting := false if _runtime != null and _runtime.has_method(&"get_operations_report"): var report_value: Variant = _runtime.call(&"get_operations_dashboard_report") if _runtime.has_method(&"get_operations_dashboard_report") else _runtime.call(&"get_operations_report") if report_value is Dictionary: operations_deferred = bool(report_value.get(&"enabled", false)) \ and int(report_value.get(&"deferred_runtime_minute", -1)) >= 0 var coordinator_value: Variant = report_value.get(&"coordinator", {}) if coordinator_value is Dictionary: operations_terminal = StringName( coordinator_value.get(&"terminal_state", &"RUNNING") ) == &"BANKRUPT" var event_service: Variant = _service(&"event_service") if event_service != null and event_service.has_method(&"get_foreground_instance"): var foreground: Variant = event_service.call(&"get_foreground_instance") event_waiting = foreground != null and int(foreground.state) == 2 if phase_label != null: if operations_terminal: phase_label.text = "運営終了" elif operations_deferred: phase_label.text = "要確認" elif event_waiting: phase_label.text = "選択待ち" else: phase_label.text = "停止中" if GameClock.paused else _phase_name(GameClock.get_phase()) phase_label.add_theme_color_override("font_color", Color("e88972") if GameClock.paused else Color("f0c574")) if pause_button != null: pause_button.text = ( "破綻" if operations_terminal else ( "運営再試行" if operations_deferred else ( "イベント待ち" if event_waiting else ("再開" if GameClock.paused else "停止") ) ) ) pause_button.disabled = operations_terminal or event_waiting for raw_speed: Variant in speed_buttons: var button := speed_buttons[raw_speed] as Button button.set_pressed_no_signal(not GameClock.paused and int(raw_speed) == GameClock.speed) func _on_time_changed(current_day: int, hour: int, minute: int) -> void: time_label.text = "%d日目 %02d:%02d" % [current_day, hour, minute] _update_pause_and_speed() func _on_phase_changed(phase: GameClock.Phase) -> void: if not GameClock.paused: phase_label.text = _phase_name(phase) func _phase_name(phase: GameClock.Phase) -> String: match phase: GameClock.Phase.MORNING: return "朝" GameClock.Phase.DAY: return "昼" GameClock.Phase.EVENING: return "夕方" _: return "夜" const MANA_RESERVE := preload("res://scripts/mana/mana_reserve_cycle.gd") const RESOURCE_TITLES := {&"gold": "所持金", &"mana": "マナ", &"materials": "資材", &"faith": "信仰"} func _resource_title(resource_name: StringName) -> String: return String(RESOURCE_TITLES.get(resource_name, String(resource_name))) func _on_resource_changed(resource_name: StringName, value: int) -> void: if not resource_labels.has(resource_name): return var label := resource_labels[resource_name] as Label label.text = "%s %d" % [_resource_title(resource_name), value] if resource_name == &"mana": label.text += "/%d" % MANA_RESERVE.CAPACITY label.mouse_filter = Control.MOUSE_FILTER_STOP _refresh_mana_tooltip(label) if value <= 0: label.text += "・停止" label.add_theme_color_override("font_color", Color("ffb095")) else: label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0"))) func _refresh_mana_tooltip(label: Label = null) -> void: if label == null: label = resource_labels.get(&"mana") as Label if label == null: return var lines: Array[String] = [] if _runtime != null and _runtime.has_method(&"get_mana_reserve_report"): var report: Dictionary = _runtime.call(&"get_mana_reserve_report") lines.append("供給 +%d/%d分 消費 −%d/%d分 収支 %+d" % [int(report[&"generation"]), int(report[&"interval_minutes"]), int(report[&"demand"]), int(report[&"interval_minutes"]), int(report[&"net"])]) var flow: Dictionary = report[&"last_flow"] var stored := "蓄積 %d/%d" % [int(report[&"amount"]), int(report[&"capacity"])] if not flow.is_empty(): stored += "(直近:不足 %d・上限超過で破棄 %d)" % [int(flow[&"shortage"]), int(flow[&"overflow"])] lines.append(stored) else: lines.append("世界樹が%d分ごとに%dマナを生成し、施設の維持マナを先に支払って残りを最大%dまで蓄えます。" % [MANA_RESERVE.INTERVAL_MINUTES, MANA_RESERVE.WORLD_TREE_GENERATION, MANA_RESERVE.CAPACITY]) lines.append("残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。") lines.append("残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。") label.tooltip_text = "\n".join(lines) func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button: var button := Button.new() button.text = text_value button.custom_minimum_size = Vector2(minimum_width, 28) button.add_theme_font_size_override("font_size", 12) button.pressed.connect(callback) _apply_button_style(button) return button func _hud_value_label(text_value: String, minimum_width: float) -> Label: var label := Label.new() label.text = text_value label.custom_minimum_size.x = minimum_width label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 12) label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0"))) return label func _icon_rect(icon_texture: Texture2D, side: float) -> TextureRect: var icon := TextureRect.new() icon.texture = icon_texture icon.custom_minimum_size = Vector2(side, side) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.mouse_filter = Control.MOUSE_FILTER_IGNORE return icon func _apply_button_icon(button: Button, icon_texture: Texture2D, maximum_width: int) -> void: if icon_texture == null: return button.icon = icon_texture button.add_theme_constant_override("icon_max_width", maximum_width) button.expand_icon = true 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")) button.add_theme_color_override("font_disabled_color", Color("a98e68")) button.add_theme_stylebox_override("normal", _themed_panel_style(&"button_normal", _visual_color(&"button_normal", Color("75451f")), _visual_color(&"border_gold", Color("b77b37")), 7, 7)) button.add_theme_stylebox_override("hover", _themed_panel_style(&"button_hover", _visual_color(&"button_hover", Color("8d5628")), _visual_color(&"border_gold", Color("e4b45c")), 7, 7)) button.add_theme_stylebox_override("pressed", _themed_panel_style(&"button_pressed", _visual_color(&"button_pressed", Color("496528")), _visual_color(&"border_gold", Color("d8bd61")), 7, 7)) button.add_theme_stylebox_override("focus", _themed_panel_style(&"button_pressed", _visual_color(&"button_pressed", Color("496528")), _visual_color(&"border_gold", Color("f2d97d")), 7, 7)) button.add_theme_stylebox_override("disabled", _themed_panel_style(&"button_disabled", _visual_color(&"button_disabled", Color("574334")), Color("7b6955"), 7, 7)) func _section_heading(text_value: String) -> Label: var heading := Label.new() heading.text = text_value heading.add_theme_font_size_override("font_size", 14) heading.add_theme_color_override("font_color", _visual_color(&"text_dark", Color("55371e"))) LayerSkin.register_text(heading, GameState.active_layer) return heading func _panel_style(color: Color, border_color: Color, radius := 8, margin := 12) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color style.border_color = border_color style.set_border_width_all(1) style.set_corner_radius_all(mini(radius, 1)) style.set_content_margin_all(margin) style.shadow_size = 0 return style func _themed_panel_style(skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox: # Small flat frames keep attention on the animated cross-section. var style := _panel_style(color, border_color, mini(radius, 1), mini(margin, 6)) LayerSkin.register_style(style, skin_id, GameState.active_layer) return style func _set_status(message: String, success: bool, quiet := false) -> void: if build_status_label == null: return build_status_label.text = message build_status_label.tooltip_text = message if quiet: build_status_label.add_theme_color_override("font_color", Color("c7d3a8") if success else Color("ffb498")) else: build_status_label.add_theme_color_override("font_color", Color("d8e8ad") if success else Color("ffb498")) func _visual_color(property_name: StringName, fallback: Color) -> Color: if visual_theme == null: return fallback var value: Variant = visual_theme.get(property_name) return value if value is Color else fallback func _service(property_name: StringName) -> Variant: if _runtime == null or not _runtime is Object: return null for property: Dictionary in _runtime.get_property_list(): if StringName(property.get(&"name", &"")) == property_name: return _runtime.get(property_name) return null func _call_array(service: Variant, method_name: StringName) -> Array: if service == null or not service.has_method(method_name): return [] var result: Variant = service.call(method_name) return result if result is Array else [] func _facility_definition(definition_id: StringName) -> Variant: var registry: Variant = _service(&"definition_registry") if registry == null or not registry.has_method(&"get_facility"): return null return registry.call(&"get_facility", definition_id) func _construction_facility(instance_id: StringName) -> Dictionary: var construction: Variant = _service(&"construction_service") if construction == null or not construction.has_method(&"get_facility"): return {} var result: Variant = construction.call(&"get_facility", instance_id) return result if result is Dictionary else {} func _facility_reasons(reasons_value: Variant) -> String: if not reasons_value is Array: return "" var names: Array[String] = [] for reason_value: Variant in reasons_value: var reason := StringName(reason_value) names.append(String(FACILITY_REASON_NAMES.get(reason, reason))) return "・".join(names) func _definition_value(source: Variant, property_name: StringName, fallback: Variant) -> Variant: if source is Dictionary: return source.get(property_name, source.get(String(property_name), fallback)) if source == null or not source is Object: return fallback for property: Dictionary in source.get_property_list(): if StringName(property.get(&"name", &"")) == property_name: return source.get(property_name) return fallback func _ensure_guest_observer() -> GuestObservationPanel: if guest_observer == null: guest_observer = AcceptDialog.new() guest_observer.title = "来客の様子" guest_observer.ok_button_text = "閉じる" var content := preload("res://scripts/ui/guest_observation_panel.gd").new() content.configure(_runtime) var scroll := ScrollContainer.new() scroll.name = "GuestObservationScroll" scroll.custom_minimum_size = Vector2(420, 340) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED content.size_flags_horizontal = Control.SIZE_EXPAND_FILL guest_observer.add_child(scroll) scroll.add_child(content) add_child(guest_observer) return guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel func _show_guest_observer() -> void: var panel := _ensure_guest_observer() guest_observer.popup_centered(Vector2i(460, 410)) panel.refresh() func _preview_dimension_migration() -> void: var report: Dictionary = _runtime.preview_facility_dimensions_at_path(manual_save_path) if not report.get(&"ok",false): _set_status("旧寸法の修正を検証できませんでした。保存データは変更していません。",false) return if report.get(&"changes",[]).is_empty(): _set_status("この手動セーブに旧寸法の施設はありません。通常の読込を利用してください。",true) return _dimension_migration_digest=report[&"source_digest"] if dimension_migration_dialog==null: dimension_migration_dialog=ConfirmationDialog.new() dimension_migration_dialog.name="DimensionMigrationDialog" dimension_migration_dialog.title="旧寸法を修正して読み込む" dimension_migration_dialog.ok_button_text="修正して読込" dimension_migration_dialog.cancel_button_text="戻る" dimension_migration_dialog.confirmed.connect(_confirm_dimension_migration) dimension_migration_dialog.canceled.connect(func(): _dimension_migration_digest="") add_child(dimension_migration_dialog) var lines: PackedStringArray=[] for change: Dictionary in report[&"changes"]: lines.append("%s%s → %d×%d" % [change[&"name"],"(建設中)" if change[&"building"] else "",change[&"size"].x,change[&"size"].y]) dimension_migration_dialog.dialog_text="保存済みの手動セーブを修正します。\n"+"\n".join(lines)+"\n\n修正前のバックアップを残します。\n現在の未保存の進行は読み込みで置き換わります。" dimension_migration_dialog.popup_centered(Vector2i(520,280)) func _confirm_dimension_migration() -> void: if _dimension_migration_digest.is_empty(): return var expected := _dimension_migration_digest _dimension_migration_digest="" var report: Dictionary=_runtime.migrate_facility_dimensions_at_path(manual_save_path,&"",expected) if not report.get(&"ok",false): _set_status("旧寸法の修正に失敗しました。読込は行っていません。保存データを確認してください。",false) return _load_manual(true) if build_status_label.text=="手動セーブを読み込みました。": _set_status("旧寸法を修正して読み込みました。修正前のバックアップを保持しています。",true) func _on_defense_notice_input(event: InputEvent) -> void: if (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT) or event.is_action_pressed(&"ui_accept"): alert_count_label.accept_event() _show_defense_manager() func _ensure_defense_manager() -> DefenseManagementPanel: if defense_manager == null: defense_manager = AcceptDialog.new() defense_manager.name = "DefenseManager" defense_manager.title = "世界樹の防衛" defense_manager.ok_button_text = "閉じる" var dialog_theme := Theme.new() dialog_theme.set_stylebox("panel", "AcceptDialog", _themed_panel_style(&"panel_parchment", Color("f4e3bd"), Color("a96f2f"), 8, 10)) var window_border := _themed_panel_style(&"panel_outer", Color("49311f"), Color("a96f2f"), 8, 10) window_border.expand_margin_top = 32 dialog_theme.set_stylebox("embedded_border", "Window", window_border) dialog_theme.set_color("title_color", "Window", Color("fff0cf")) defense_manager.theme = dialog_theme _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" scroll.custom_minimum_size = Vector2(480, 460) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL defense_manager.add_child(scroll) scroll.add_child(panel) add_child(defense_manager) return defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel func _show_defense_manager() -> void: var panel := _ensure_defense_manager() panel.refresh() LayerSkin.apply_tree(defense_manager, GameState.active_layer) defense_manager.popup_centered(Vector2i(520, 540)) 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.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 var id_value: Variant = selection.get("defense_id", "") if not id_value is String or id_value.is_empty() or id_value.length() > 256: return var raid_id := StringName(id_value) if _service(&"defense_service").get_management_detail(raid_id).is_empty(): return _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) func _toggle_demolition_mode() -> void: _set_demolition_mode(not _demolition_mode) func _set_demolition_mode(enabled: bool, announce := true) -> void: if enabled: _set_pan_mode(false) _set_excavation_mode(false, false) _clear_build_selection(false) _demolition_mode = enabled _demolition_target = &"" if demolition_dialog != null: demolition_dialog.hide() if demolition_button != null: demolition_button.set_pressed_no_signal(enabled) if world_preview != null: world_preview.set_demolition_mode(enabled) if announce: _set_status("撤去する施設のマスを選択してください。確認画面で取り消せます。" if enabled else "撤去モードを解除しました。", true, true) func _preview_demolition(cell: Vector3i) -> void: # Selection only; Runtime owns eligibility, occupancy and refunds. _demolition_target = world_preview.facility_at_canvas(world_preview.cell_to_canvas(cell)) world_preview.selected_entity_id = _demolition_target if demolition_dialog == null: demolition_dialog = ConfirmationDialog.new() demolition_dialog.name = "DemolitionDialog" demolition_dialog.title = "施設の撤去" demolition_dialog.ok_button_text = "撤去する" demolition_dialog.cancel_button_text = "戻る" demolition_dialog.confirmed.connect(_confirm_demolition) demolition_dialog.canceled.connect(_cancel_demolition) add_child(demolition_dialog) _apply_button_style(demolition_dialog.get_ok_button()) _apply_button_style(demolition_dialog.get_cancel_button()) demolition_dialog.dialog_text = "座標 (%d, %d / %d)\n施設: %s\n\n撤去を指示しますか?" % [cell.x, cell.y, cell.z, String(_demolition_target)] demolition_dialog.popup_centered(Vector2i(420, 210)) func _cancel_demolition() -> void: _demolition_target = &"" _set_status("撤去を取り消しました。", true, true) func _confirm_demolition() -> void: if not _demolition_mode: return if _runtime == null or not _runtime.has_method(&"submit_command"): _set_status("Simulation Runtimeへ接続できません。", false) return var target := _demolition_target _demolition_target = &"" var result: CommandResult = _runtime.submit_command(&"demolish_facility", {&"instance_id": target}) # Paused play returns execution synchronously; running play returns acceptance. if result.is_success() and _runtime.command_queue.get_processed_result(result.command_id) == null: _pending_demolition_commands[result.command_id] = target _set_status("撤去を受け付けました", true) return _show_demolition_result(result) func _show_demolition_result(result: CommandResult) -> void: if result.is_success(): var refund: Dictionary = result.details.get(&"refund", {}) var keys: Array = refund.keys() keys.sort() var amounts: PackedStringArray = [] for key: Variant in keys: if int(refund[key]) != 0: amounts.append("%s %d" % [_resource_title(StringName(key)), int(refund[key])]) _set_status("撤去しました(返金: %s)" % "、".join(amounts) if not amounts.is_empty() else "撤去しました(返金なし)", true) else: _set_status("撤去できません: " + result.message, false) _refresh_selected_entity() world_preview.queue_redraw()