class_name ABGEntryShell extends Control const BootService = preload("res://entry/boot_service.gd") const SettingsServiceScript = preload("res://foundation/settings_service.gd") const AppServicesScript = preload("res://foundation/app_services.gd") const UIFactory = preload("res://ui/shared/ui_factory.gd") const UITokens = preload("res://foundation/ui_tokens.gd") const ScreenContextScript = preload("res://foundation/screen_context.gd") const PageContextScript = preload("res://foundation/page_context.gd") const RecoveryPageViewScript = preload("res://entry/views/recovery_page_view.gd") const RewardChoicePageViewScript = preload("res://entry/views/reward_choice_page_view.gd") const KnockoutReviewPageViewScript = preload("res://entry/views/knockout_review_page_view.gd") const EntryChrome = preload("res://entry/entry_chrome.gd") const InitialCompanyArt = preload("res://entry/initial_company_art.gd") const GuildDestinationSticker = preload("res://entry/guild_destination_sticker.gd") const GUILD_CONTRACT_BOARD = preload("res://assets/entry/guild_props/contract_board.png") const GUILD_ROUTE_MAP = preload("res://assets/entry/guild_props/route_map.png") const InitialCompanyDraftService = preload("res://entry/initial_company_draft_service.gd") const CompanyPageControllerScript = preload("res://entry/company_page_controller.gd") const GuildPageControllerScript = preload("res://entry/guild_page_controller.gd") const PartyLinePageControllerScript = preload("res://entry/party_line_page_controller.gd") const ReadinessPageControllerScript = preload("res://entry/readiness_page_controller.gd") const RiskBoardPageControllerScript = preload("res://entry/risk_board_page_controller.gd") const RiskCommitmentPageControllerScript = preload("res://entry/risk_commitment_page_controller.gd") const BattleOpeningPageControllerScript = preload("res://entry/battle_opening_page_controller.gd") const BattleLivePageControllerScript = preload("res://entry/battle_live_page_controller.gd") const BattleResultPageControllerScript = preload("res://entry/battle_result_page_controller.gd") const KnockoutReviewPageControllerScript = preload("res://entry/knockout_review_page_controller.gd") const RewardChoicePageControllerScript = preload("res://entry/reward_choice_page_controller.gd") const RecoveryPageControllerScript = preload("res://entry/recovery_page_controller.gd") const BattleResultPageViewScript = preload("res://entry/views/battle_result_page_view.gd") const RiskCommitmentPageViewScript = preload("res://entry/views/risk_commitment_page_view.gd") const ReadinessPageViewScript = preload("res://entry/views/readiness_page_view.gd") const RiskBoardPageViewScript = preload("res://entry/views/risk_board_page_view.gd") const PartyLinePageViewScript = preload("res://entry/views/party_line_page_view.gd") const RecruitReviewPageViewScript = preload("res://entry/views/recruit_review_page_view.gd") const MarketPageViewScript = preload("res://entry/views/market_page_view.gd") const ContractChoicePageViewScript = preload("res://entry/views/contract_choice_page_view.gd") const GuildPageViewScript = preload("res://entry/views/guild_page_view.gd") const CompanyRosterPageViewScript = preload("res://entry/views/company_roster_page_view.gd") const RecruitDetailPageViewScript = preload("res://entry/views/recruit_detail_page_view.gd") const MarketPageControllerScript = preload("res://entry/market_page_controller.gd") const RecruitReviewPageControllerScript = preload("res://entry/recruit_review_page_controller.gd") const ContractChoicePageControllerScript = preload("res://entry/contract_choice_page_controller.gd") const GuildContractService = preload("res://entry/guild_contract_service.gd") const FormationRiskService = preload("res://entry/formation_risk_service.gd") const BattleService = preload("res://entry/battle_service.gd") const BattleOpeningArt = preload("res://entry/battle_opening_art.gd") const BattleLiveArt = preload("res://entry/battle_live_art.gd") const RuntimeContentRegistry = preload("res://p10_0/content_registry.gd") const MarketLocalization = preload("res://entry/market_localization.gd") const InitialCompanyPage = preload("res://entry/initial_company_page.gd") const GUILD_BACKGROUND = preload("res://assets/backgrounds/p9_3_guild_hall.png") const CONTENT_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png") const TITLE_MASTER_V002 = preload("res://assets/backgrounds/title_master_v002.png") const M52_TITLE_BACKGROUND = preload("res://assets/backgrounds/m52_title_background.png") const M52_Q_TITLE_BACKGROUND = preload("res://assets/backgrounds/m52_q_title_background.png") const BOOT_BACKGROUND = preload("res://assets/entry/boot/background.png") const BOOT_IDENTITY_PLAQUE = preload("res://assets/entry/boot/identity-plaque.png") const BOOT_VERIFICATION_RAIL = preload("res://assets/entry/boot/verification-rail.png") const BOOT_STATUS_SEAL = preload("res://assets/entry/boot/status-seal.png") const BOOT_ACTION_TAB = preload("res://assets/entry/boot/action-tab.png") const FIRST_LAUNCH_BACKGROUND = preload("res://assets/entry/first_launch/background.png") const FIRST_LAUNCH_PREVIEW_FRAME = preload("res://assets/entry/first_launch/preview-frame.png") const FIRST_LAUNCH_SETTING_ROW = preload("res://assets/entry/first_launch/setting-row.png") const FIRST_LAUNCH_APPLY_COMMAND = preload("res://assets/entry/first_launch/apply-command.png") const FIRST_LAUNCH_RETURN_TAB = preload("res://assets/entry/first_launch/return-tab.png") const FIRST_LAUNCH_SETTING_ICON = preload("res://assets/entry/first_launch/setting-icon.png") const FIRST_LAUNCH_VALUE_PLATE = preload("res://assets/entry/first_launch/value-plate.png") const GUILD_MASTER_BACKGROUND = preload("res://assets/backgrounds/guild_master_b_background.png") const GUILD_MASTER_COMPOSITE = preload("res://assets/backgrounds/guild_master_b_composite.png") const GUILD_MASTER_BELLKEEPER = preload("res://assets/entry/guild_master_b_characters/company_bellkeeper.png") const GUILD_MASTER_SCOUT = preload("res://assets/entry/guild_master_b_characters/company_scout.png") const GUILD_MASTER_TOP_RAIL = preload("res://assets/entry/guild_master_b_ui/top_rail.png") const GUILD_MASTER_BOTTOM_RAIL = preload("res://assets/entry/guild_master_b_ui/bottom_rail.png") const GUILD_MASTER_KIOSK = preload("res://assets/entry/guild_master_b_ui/contract_kiosk.png") const GUILD_MASTER_COMPASS = preload("res://assets/entry/guild_master_b_ui/route_compass.png") const GUILD_MASTER_MEDALLION = preload("res://assets/entry/guild_master_b_ui/primary_medallion.png") const GUILD_DESK_WORLD = preload("res://assets/entry/guild_desk_v001/world.png") const GUILD_DESK_CONTRACT_BOARD = preload("res://assets/entry/guild_desk_v001/contract-board.png") const GUILD_DESK_RESOURCE_BADGE = preload("res://assets/entry/guild_desk_v001/resource-badge.png") const GUILD_DESK_TOP_LEFT = preload("res://assets/entry/guild_desk_v001/top_rail/left.png") const GUILD_DESK_TOP_CENTER = preload("res://assets/entry/guild_desk_v001/top_rail/center.png") const GUILD_DESK_TOP_RIGHT = preload("res://assets/entry/guild_desk_v001/top_rail/right.png") const GUILD_DESK_TOP_PRESSURE = preload("res://assets/entry/guild_desk_v001/top_rail/pressure.png") const GUILD_DESK_TOP_COIN = preload("res://assets/entry/guild_desk_v001/top_rail/coin.png") const GUILD_DESK_TOP_COMPANY = preload("res://assets/entry/guild_desk_v001/top_rail/company.png") const GUILD_DESK_BOTTOM_LEFT = preload("res://assets/entry/guild_desk_v001/bottom_rail/left.png") const GUILD_DESK_BOTTOM_CENTER = preload("res://assets/entry/guild_desk_v001/bottom_rail/center.png") const GUILD_DESK_BOTTOM_RIGHT = preload("res://assets/entry/guild_desk_v001/bottom_rail/right.png") const GUILD_DESK_BOTTOM_ROSTER = preload("res://assets/entry/guild_desk_v001/bottom_rail/roster.png") const GUILD_DESK_BOTTOM_EQUIPMENT = preload("res://assets/entry/guild_desk_v001/bottom_rail/equipment.png") const GUILD_DESK_BOTTOM_PARTY = preload("res://assets/entry/guild_desk_v001/bottom_rail/party.png") const BATTLE_OPENING_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png") const BATTLE_OPENING_ENEMY = preload("res://assets/enemies/riftblade/idle.png") const BATTLE_OPENING_HEARTHGUARD = preload("res://assets/actors/hearthguard.png") const BATTLE_OPENING_WAYRUNNER = preload("res://assets/actors/wayrunner.png") const BATTLE_OPENING_FARSTRING = preload("res://assets/actors/farstring.png") const BATTLE_OPENING_KINDHAND = preload("res://assets/actors/kindhand.png") const UI_FONT = preload("res://assets/fonts/NotoSansSC.ttf") const BATTLE_LIVE_STAGE_SECONDS := { "waiting_for_next_event": 0.48, "event_forecast": 0.72, "action_committed": 0.46, "event_resolving": 0.46, "event_resolved": 0.78, } signal boot_completed @export var auto_start := true var boot_service := BootService.new() var settings_service := SettingsServiceScript.new() ## Compatibility alias for existing focused fixtures. New page code should use ## `settings_service` or resolve the service through `app_services`. var settings_store: get: return settings_service set(value): settings_service = value var app_services = AppServicesScript.new({"settings": settings_service}) var settings_context var initial_company_service := InitialCompanyDraftService.new() var company_page_controller := CompanyPageControllerScript.new() var guild_page_controller := GuildPageControllerScript.new() var party_line_page_controller := PartyLinePageControllerScript.new() var readiness_page_controller := ReadinessPageControllerScript.new() var risk_board_page_controller := RiskBoardPageControllerScript.new() var risk_commitment_page_controller := RiskCommitmentPageControllerScript.new() var battle_opening_page_controller := BattleOpeningPageControllerScript.new() var battle_live_page_controller := BattleLivePageControllerScript.new() var battle_result_page_controller := BattleResultPageControllerScript.new() var knockout_review_page_controller := KnockoutReviewPageControllerScript.new() var reward_choice_page_controller := RewardChoicePageControllerScript.new() var recovery_page_controller := RecoveryPageControllerScript.new() var market_page_controller := MarketPageControllerScript.new() var recruit_review_page_controller := RecruitReviewPageControllerScript.new() var contract_choice_page_controller := ContractChoicePageControllerScript.new() var guild_contract_service := GuildContractService.new() var formation_risk_service := FormationRiskService.new() var battle_service := BattleService.new() var initial_company_page: Control var settings: Dictionary = {} var setup_draft: Dictionary = {} var boot_snapshot: Dictionary = {} var boot_generation := 0 var status_label: Label var detail_label: Label var progress_bar: ProgressBar var retry_button: Button var diagnostics_label: Label var boot_diagnostics_button: Button var boot_exit_button: Button var boot_responsive_layout: Control var boot_identity_plaque: TextureRect var boot_verification_rail: NinePatchRect var boot_status_seals: Array[TextureRect] = [] var boot_status_marks: Array[Label] = [] var boot_progress_value: Label var boot_diagnostics_expanded := false var first_launch_responsive_layout: Control var first_launch_preview_frame: NinePatchRect var first_launch_preview_image: TextureRect var first_launch_preview_title_plate: NinePatchRect var first_launch_preview_detail_plate: NinePatchRect var first_launch_settings_panel: Control var first_launch_apply: Button var first_launch_error: Label var first_launch_setting_buttons: Dictionary = {} var first_launch_setting_values: Dictionary = {} var current_screen := "boot" var title_notice := "" var settings_draft: Dictionary = {} var settings_category := "display" var settings_notice := "" var settings_caller := "title" var guild_company: Dictionary = {} var guild_notice := "" var guild_view_data: Dictionary = {} var contract_difficulty := "standard" var contract_notice := "" var market_category := "recruit" var market_selected_kind := "" var market_selected_offer_id := "" var market_notice := "" var market_content: Dictionary = RuntimeContentRegistry.snapshot() var market_view_data: Dictionary = {} var recruit_review_view_data: Dictionary = {} var contract_choice_view_data: Dictionary = {} var party_draft_order: Array[String] = [] var party_undo_order: Array[String] = [] var party_selected_recruit_id := "" var party_preview: Dictionary = {} var party_view_data: Dictionary = {} var readiness_view_data: Dictionary = {} var party_notice := "" var roster_selected_recruit_id := "" var dismissal_recruit_id := "" var dismissal_return_screen := "roster" var dismissal_notice := "" var contract_return_notice := "" var risk_selected_id := "" var risk_view_data: Dictionary = {} var risk_commitment_view_data: Dictionary = {} var risk_commitment_acknowledged := false var risk_commitment_bound_risk_id := "" var risk_commitment_bound_snapshot_hash := "" var risk_commitment_bound_option_hash := "" var risk_commitment_bound_contract_hash := "" var risk_commitment_notice := "" var risk_commitment_receipt: Dictionary = {} var battle_operation_id := "" var battle_paused := false var battle_speed := 1.0 var battle_notice := "" var battle_playback_generation := 0 var battle_playback_timer: Timer var battle_result_diagnosis_open := false var battle_result_notice := "" var battle_knockout_notice := "" var battle_reward_selected_id := "" var battle_reward_notice := "" var battle_opening_mode := false var battle_opening_state := "ready_to_observe" var battle_opening_notice := "" var battle_opening_handoff_receipt: Dictionary = {} var battle_opening_view_data: Dictionary = {} var battle_opening_canvas: Control var battle_live_canvas: Control var battle_live_state := "waiting_for_next_event" var battle_live_event: Dictionary = {} var battle_live_observation: Dictionary = {} var battle_live_resume_state := "waiting_for_next_event" var battle_live_notice := "" var battle_live_error_code := "" var battle_live_interruption: Dictionary = {} var battle_pause_log_open := false var battle_live_view_data: Dictionary = {} var battle_result_view_data: Dictionary = {} var battle_knockout_view_data: Dictionary = {} var battle_reward_view_data: Dictionary = {} var battle_recovery_view_data: Dictionary = {} var battle_recovery_view: Control var battle_reward_view: Control var battle_knockout_view: Control var battle_result_view: Control var risk_commitment_view: Control var readiness_view: Control var risk_board_view: Control var party_line_view: Control var recruit_review_view: Control var market_view: Control var contract_choice_view: Control var guild_view: Control var company_roster_view: Control var recruit_detail_view: Control var battle_live_rendering := false var battle_live_render_requested := false var title_master_design_canvas: Control var guild_master_design_canvas: Control func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED and is_instance_valid(boot_responsive_layout): _layout_boot_screen() if what == NOTIFICATION_RESIZED and is_instance_valid(first_launch_responsive_layout): _layout_first_launch() if what == NOTIFICATION_RESIZED and is_instance_valid(title_master_design_canvas): _layout_title_master_canvas() if what == NOTIFICATION_RESIZED and is_instance_valid(battle_opening_canvas): _layout_battle_opening_canvas() if what == NOTIFICATION_RESIZED and is_instance_valid(battle_live_canvas): _layout_battle_live_canvas() if what in [NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_APPLICATION_FOCUS_OUT]: if current_screen in ["battle", "battle_inspect", "battle_pause_controls"] and not battle_opening_mode and is_instance_valid(battle_live_canvas): # Focus notifications can arrive while the live canvas is replacing its # children. Defer the state transition until the parent mutation ends. call_deferred("_battle_live_interrupt", "application_paused" if what == NOTIFICATION_APPLICATION_PAUSED else "focus_lost") func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) app_services.register_service(&"guild_page_controller", guild_page_controller) app_services.register_service(&"party_line_page_controller", party_line_page_controller) app_services.register_service(&"readiness_page_controller", readiness_page_controller) app_services.register_service(&"risk_board_page_controller", risk_board_page_controller) app_services.register_service(&"risk_commitment_page_controller", risk_commitment_page_controller) app_services.register_service(&"battle_opening_page_controller", battle_opening_page_controller) app_services.register_service(&"battle_live_page_controller", battle_live_page_controller) app_services.register_service(&"battle_result_page_controller", battle_result_page_controller) app_services.register_service(&"knockout_review_page_controller", knockout_review_page_controller) app_services.register_service(&"reward_choice_page_controller", reward_choice_page_controller) app_services.register_service(&"recovery_page_controller", recovery_page_controller) app_services.register_service(&"market_page_controller", market_page_controller) app_services.register_service(&"recruit_review_page_controller", recruit_review_page_controller) app_services.register_service(&"contract_choice_page_controller", contract_choice_page_controller) tree_exiting.connect(_invalidate_battle_playback) _build_boot_screen() if auto_start: call_deferred("start_boot") func start_boot() -> void: boot_generation += 1 boot_diagnostics_expanded = false var generation := boot_generation boot_snapshot = boot_service.begin() _render_boot_state() while generation == boot_generation and String(boot_snapshot.state) == "running": await get_tree().process_frame boot_snapshot = boot_service.verify_next() _render_boot_state() if generation == boot_generation and String(boot_snapshot.state) == "complete": await get_tree().create_timer(0.18).timeout if generation == boot_generation: boot_completed.emit() _open_after_boot() func retry_boot() -> void: if String(boot_snapshot.get("state", "")) != "error": return start_boot() func show_boot_diagnostics() -> void: if String(boot_snapshot.get("state", "")) != "error": return boot_diagnostics_expanded = true diagnostics_label.visible = true _layout_boot_screen() func exit_from_boot() -> void: get_tree().quit() func _build_boot_screen() -> void: current_screen = "boot" for child in get_children(): child.free() boot_status_seals.clear() boot_status_marks.clear() var background := TextureRect.new() background.name = "BootWorld" background.texture = BOOT_BACKGROUND background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(background) var shade := ColorRect.new() shade.name = "BootWorldShade" shade.color = Color(0.035, 0.07, 0.10, 0.06) shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) shade.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(shade) boot_responsive_layout = Control.new() boot_responsive_layout.name = "BootResponsiveLayout" boot_responsive_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) boot_responsive_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(boot_responsive_layout) boot_identity_plaque = TextureRect.new() boot_identity_plaque.name = "BootIdentityPlaque" boot_identity_plaque.texture = BOOT_IDENTITY_PLAQUE boot_identity_plaque.expand_mode = TextureRect.EXPAND_IGNORE_SIZE boot_identity_plaque.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED boot_identity_plaque.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS boot_identity_plaque.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_responsive_layout.add_child(boot_identity_plaque) var identity := Label.new() identity.name = "BootIdentity" identity.text = _tr("裂脉远征团", "AETHERBOUND GUILD") identity.add_theme_font_override("font", UI_FONT) identity.add_theme_font_size_override("font_size", _scaled(18)) identity.add_theme_color_override("font_color", Color("#213744")) identity.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER identity.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS identity.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_identity_plaque.add_child(identity) boot_verification_rail = _boot_nine_patch("BootVerificationRail", BOOT_VERIFICATION_RAIL, 112, 0) boot_responsive_layout.add_child(boot_verification_rail) for index in range(3): var seal := TextureRect.new() seal.name = "BootStatusSeal%d" % index seal.texture = BOOT_STATUS_SEAL seal.expand_mode = TextureRect.EXPAND_IGNORE_SIZE seal.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED seal.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS seal.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_verification_rail.add_child(seal) boot_status_seals.append(seal) var mark := _label(str(index + 1), _scaled(13), Color("#f5ead0")) mark.name = "BootStatusMark%d" % index mark.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mark.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER mark.vertical_alignment = VERTICAL_ALIGNMENT_CENTER mark.mouse_filter = Control.MOUSE_FILTER_IGNORE seal.add_child(mark) boot_status_marks.append(mark) status_label = Label.new() status_label.name = "BootStatus" status_label.add_theme_font_override("font", UI_FONT) status_label.add_theme_font_size_override("font_size", _scaled(15)) status_label.add_theme_color_override("font_color", Color("#213744")) status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS status_label.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_verification_rail.add_child(status_label) detail_label = Label.new() detail_label.name = "BootDetail" detail_label.add_theme_font_override("font", UI_FONT) detail_label.add_theme_font_size_override("font_size", _scaled(10)) detail_label.add_theme_color_override("font_color", Color("#5d5a50")) detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail_label.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_verification_rail.add_child(detail_label) progress_bar = ProgressBar.new() progress_bar.name = "BootProgress" progress_bar.show_percentage = false progress_bar.add_theme_stylebox_override("background", _flat_style(Color("#d7c6a6"), Color("#866f47"), 1, 5)) progress_bar.add_theme_stylebox_override("fill", _flat_style(Color("#5dc9e9"), Color("#ead49b"), 1, 5)) boot_verification_rail.add_child(progress_bar) boot_progress_value = _label("0%", _scaled(9), Color("#213744")) boot_progress_value.name = "BootProgressValue" boot_progress_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT boot_progress_value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER boot_progress_value.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_verification_rail.add_child(boot_progress_value) retry_button = _boot_action_button("BootRetry", _tr("重试", "Retry"), retry_boot) retry_button.visible = false boot_verification_rail.add_child(retry_button) boot_diagnostics_button = _boot_action_button("BootDiagnosticsAction", _tr("诊断", "Diagnostics"), show_boot_diagnostics) boot_diagnostics_button.visible = false boot_verification_rail.add_child(boot_diagnostics_button) boot_exit_button = _boot_action_button("BootExit", _tr("退出", "Exit"), exit_from_boot) boot_exit_button.visible = false boot_verification_rail.add_child(boot_exit_button) diagnostics_label = Label.new() diagnostics_label.name = "BootDiagnostics" diagnostics_label.add_theme_font_override("font", UI_FONT) diagnostics_label.add_theme_font_size_override("font_size", _scaled(9)) diagnostics_label.add_theme_color_override("font_color", Color("#914b32")) diagnostics_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART diagnostics_label.mouse_filter = Control.MOUSE_FILTER_IGNORE boot_verification_rail.add_child(diagnostics_label) _layout_boot_screen() func _layout_boot_screen() -> void: if not is_instance_valid(boot_responsive_layout) or not is_instance_valid(boot_verification_rail): return var available := size if available.x <= 0.0 or available.y <= 0.0: return var safe_x := clampf(available.x * 0.04, 20.0, 48.0) var safe_y := clampf(available.y * 0.06, 16.0, 40.0) var plaque_width := clampf(available.x * 0.26, 180.0, 320.0) var plaque_height := clampf(plaque_width / 1.88, 72.0, 150.0) boot_identity_plaque.position = Vector2(safe_x, safe_y) boot_identity_plaque.size = Vector2(plaque_width, plaque_height) var identity := boot_identity_plaque.get_node_or_null("BootIdentity") as Label if identity != null: identity.position = Vector2(plaque_width * 0.14, plaque_height * 0.26) identity.size = Vector2(plaque_width * 0.72, plaque_height * 0.48) var rail_width := minf(available.x - safe_x * 2.0, minf(1120.0, available.x * 0.84)) var rail_height := clampf(available.y * 0.25, 96.0, 176.0) boot_verification_rail.position = Vector2((available.x - rail_width) * 0.5, available.y - safe_y - rail_height) boot_verification_rail.size = Vector2(rail_width, rail_height) var padding := clampf(rail_height * 0.12, 10.0, 18.0) var content_top := clampf(rail_height * 0.28, 27.0, 50.0) var content_bottom := clampf(rail_height * 0.17, 16.0, 30.0) var error_state := String(boot_snapshot.get("state", "running")) == "error" var seal_size := clampf(rail_height * 0.42, 36.0, 64.0) var seal_gap := clampf(seal_size * 0.12, 4.0, 8.0) var seals_width := seal_size * 3.0 + seal_gap * 2.0 for index in range(boot_status_seals.size()): var seal := boot_status_seals[index] seal.visible = not error_state seal.position = Vector2(padding + index * (seal_size + seal_gap), (rail_height - seal_size) * 0.5) seal.size = Vector2(seal_size, seal_size) if error_state: var inner_left := clampf(rail_height * 0.9, 84.0, 120.0) var inner_right := inner_left var inner_width := rail_width - inner_left - inner_right var copy_width := inner_width * 0.42 status_label.position = Vector2(inner_left, content_top) status_label.size = Vector2(copy_width, 26) detail_label.position = Vector2(inner_left, content_top + 27.0) detail_label.size = Vector2(copy_width, maxf(18.0, rail_height - content_top - content_bottom - 50.0)) diagnostics_label.position = Vector2(inner_left, rail_height - content_bottom - 23.0) diagnostics_label.size = Vector2(copy_width, 23.0) progress_bar.visible = false boot_progress_value.visible = false var action_x := inner_left + copy_width + padding var action_width := rail_width - inner_right - action_x var visible_actions: Array[Button] = [] for action in [retry_button, boot_diagnostics_button, boot_exit_button]: if action.visible: visible_actions.append(action) var action_gap := 6.0 var button_width := (action_width - action_gap * maxf(0.0, visible_actions.size() - 1.0)) / maxf(1.0, visible_actions.size()) for index in range(visible_actions.size()): visible_actions[index].position = Vector2(action_x + index * (button_width + action_gap), (rail_height - 48.0) * 0.5) visible_actions[index].size = Vector2(button_width, 48.0) else: var copy_x := padding + seals_width + maxf(10.0, padding * 0.8) var copy_width := rail_width - copy_x - padding status_label.position = Vector2(copy_x, content_top) status_label.size = Vector2(copy_width, 24.0) detail_label.position = Vector2(copy_x, content_top + 24.0) detail_label.size = Vector2(copy_width, maxf(18.0, rail_height - content_top - content_bottom - 56.0)) var progress_y := rail_height - content_bottom - 16.0 progress_bar.position = Vector2(copy_x, progress_y) progress_bar.size = Vector2(maxf(80.0, copy_width - 54.0), 12.0) progress_bar.visible = true boot_progress_value.position = Vector2(copy_x + copy_width - 50.0, progress_y - 5.0) boot_progress_value.size = Vector2(50.0, 22.0) boot_progress_value.visible = true diagnostics_label.visible = false func _boot_nine_patch(node_name: String, texture: Texture2D, horizontal_margin: int, vertical_margin: int) -> NinePatchRect: var panel := NinePatchRect.new() panel.name = node_name panel.texture = texture panel.patch_margin_left = horizontal_margin panel.patch_margin_right = horizontal_margin panel.patch_margin_top = vertical_margin panel.patch_margin_bottom = vertical_margin panel.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS panel.mouse_filter = Control.MOUSE_FILTER_IGNORE return panel func _boot_action_button(node_name: String, text_value: String, callback: Callable) -> Button: var button := Button.new() button.name = node_name button.text = text_value button.custom_minimum_size = Vector2(72.0, 48.0) button.add_theme_font_override("font", UI_FONT) button.add_theme_font_size_override("font_size", _scaled(10)) button.add_theme_color_override("font_color", Color("#f5ead0")) button.add_theme_color_override("font_hover_color", Color.WHITE) button.add_theme_stylebox_override("normal", _boot_texture_style(Color.WHITE)) button.add_theme_stylebox_override("hover", _boot_texture_style(Color("#d9f4ff"))) button.add_theme_stylebox_override("pressed", _boot_texture_style(Color("#b7cfdd"))) button.add_theme_stylebox_override("focus", _boot_texture_style(Color("#f4dfa5"))) button.pressed.connect(callback) return button func _boot_texture_style(modulate: Color) -> StyleBoxTexture: var style := StyleBoxTexture.new() style.texture = BOOT_ACTION_TAB style.texture_margin_left = 20.0 style.texture_margin_right = 20.0 style.texture_margin_top = 8.0 style.texture_margin_bottom = 8.0 style.modulate_color = modulate return style func _render_boot_state() -> void: if status_label == null: return var identity := boot_identity_plaque.get_node_or_null("BootIdentity") as Label if identity != null: identity.text = _tr("裂脉远征团", "AETHERBOUND GUILD") retry_button.text = _tr("重试", "Retry") boot_diagnostics_button.text = _tr("诊断", "Diagnostics") boot_exit_button.text = _tr("退出", "Exit") var state := String(boot_snapshot.get("state", "running")) var step := String(boot_snapshot.get("step", "resources")) var total: int = maxi(1, int(boot_snapshot.get("total", 1))) var completed := int(boot_snapshot.get("completed", 0)) progress_bar.max_value = total progress_bar.value = completed var percent := int(round(float(completed) / float(total) * 100.0)) boot_progress_value.text = "%d%%" % percent var error_code := String(boot_snapshot.get("code", "UNKNOWN")) var recoverable := state == "error" and _boot_error_is_recoverable(error_code) retry_button.visible = recoverable boot_diagnostics_button.visible = state == "error" boot_exit_button.visible = state == "error" diagnostics_label.visible = state == "error" and boot_diagnostics_expanded if state != "error": boot_diagnostics_expanded = false var complete_seals := 3 if state == "complete" else clampi(int(floor(float(completed) / float(total) * 3.0)), 0, 3) for index in range(boot_status_seals.size()): var finished := index < complete_seals var active := index == complete_seals and state == "running" boot_status_seals[index].modulate = Color.WHITE if finished or active else Color(0.62, 0.67, 0.70, 0.88) boot_status_marks[index].text = "OK" if finished else "..." if active else str(index + 1) boot_status_marks[index].add_theme_color_override("font_color", Color("#9ee9ff") if finished else Color("#f5ead0")) match state: "error": status_label.text = _tr("本地检查需要处理", "Verification needs attention") detail_label.text = _tr("本地数据保持不变。可重试的检查会显示重试操作;不安全的存档会保持停止。", "Local data was left unchanged. Recoverable checks offer Retry; unsafe save data remains stopped.") diagnostics_label.text = "%s: %s" % [_tr("诊断", "Diagnostic"), error_code] "complete": status_label.text = _tr("本地检查完成", "Local verification complete") detail_label.text = _tr("资源、设置存储与存档访问均已就绪。", "Resources, settings storage and save access are ready.") diagnostics_label.text = "" _: status_label.text = _tr("正在准备公会门槛", "Preparing the guild threshold") detail_label.text = _step_text(step) diagnostics_label.text = "" _layout_boot_screen() func _boot_error_is_recoverable(code: String) -> bool: return code != "SAVE_REVIEW_REQUIRED" func _step_text(step: String) -> String: match step: "resources": return _tr("正在检查必需的本地资源...", "Checking required local resources...") "storage": return _tr("正在检查本地存储访问...", "Checking local storage access...") "preferences": return _tr("正在检查显示与辅助设置...", "Checking display and accessibility settings...") "save": return _tr("正在检查最近的本地远征记录...", "Checking the latest local run record...") return _tr("正在完成本地检查...", "Completing local verification...") func _open_after_boot() -> void: var loaded: Dictionary = settings_service.load_settings() if not loaded.ok: boot_snapshot = {"state": "error", "step": "preferences", "code": String(loaded.code), "completed": 0, "total": 1} _build_boot_screen() _render_boot_state() return settings = loaded.settings.duplicate(true) if bool(settings.setup_completed): var company := initial_company_service.load_company() if company.ok: _open_company_destination(company.company) else: if String(company.code) != "COMPANY_MISSING": title_notice = _tr("远征团存档需要处理,已停止创建新游戏。", "Company data needs attention; New Game is blocked.") + " [%s]" % String(company.code) show_title() else: show_first_launch() func show_first_launch() -> void: setup_draft = settings.duplicate(true) if not settings.is_empty() else settings_service.defaults() for child in get_children(): child.free() first_launch_setting_buttons.clear() first_launch_setting_values.clear() var background := TextureRect.new() background.name = "FirstLaunchWorld" background.texture = FIRST_LAUNCH_BACKGROUND background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(background) var shade := ColorRect.new() shade.name = "FirstLaunchShade" shade.color = Color(0.02, 0.05, 0.07, 0.09) shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) shade.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(shade) first_launch_responsive_layout = Control.new() first_launch_responsive_layout.name = "FirstLaunchResponsiveLayout" first_launch_responsive_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) first_launch_responsive_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(first_launch_responsive_layout) first_launch_preview_frame = _first_nine_patch("FirstLaunchPreviewFrame", FIRST_LAUNCH_PREVIEW_FRAME, 86, 74) first_launch_responsive_layout.add_child(first_launch_preview_frame) first_launch_preview_image = TextureRect.new() first_launch_preview_image.name = "FirstLaunchPreviewImage" first_launch_preview_image.texture = FIRST_LAUNCH_BACKGROUND first_launch_preview_image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE first_launch_preview_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED first_launch_preview_image.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS first_launch_preview_image.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(first_launch_preview_image) var preview_guide := Panel.new() preview_guide.name = "FirstLaunchSafeGuide" preview_guide.add_theme_stylebox_override("panel", _first_outline_style(Color("#e5f7ff"), 2, 0.82)) preview_guide.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(preview_guide) var preview_heading := _label(_tr("即时预览", "LIVE PREVIEW"), _scaled(13), Color("#f5ead0")) preview_heading.name = "FirstLaunchPreviewHeading" preview_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER preview_heading.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(preview_heading) first_launch_preview_title_plate = _first_nine_patch("SetupPreviewTitlePlate", FIRST_LAUNCH_VALUE_PLATE, 72, 0) first_launch_preview_title_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(first_launch_preview_title_plate) var preview_title := _label(_tr("裂脉远征团", "AETHERBOUND GUILD"), _scaled(20), Color("#213744")) preview_title.name = "SetupPreviewTitle" preview_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER preview_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER preview_title.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(preview_title) first_launch_preview_detail_plate = _first_nine_patch("SetupPreviewDetailPlate", FIRST_LAUNCH_VALUE_PLATE, 72, 0) first_launch_preview_detail_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(first_launch_preview_detail_plate) var preview_detail := _label(_tr("安全区 · 字号 · 字幕", "Safe area · text · captions"), _scaled(10), Color("#3c403e")) preview_detail.name = "SetupPreviewDetail" preview_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER preview_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART preview_detail.clip_text = true preview_detail.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN preview_detail.size_flags_vertical = Control.SIZE_SHRINK_BEGIN preview_detail.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_preview_frame.add_child(preview_detail) first_launch_settings_panel = Control.new() first_launch_settings_panel.name = "FirstLaunchControls" first_launch_settings_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE first_launch_responsive_layout.add_child(first_launch_settings_panel) var heading := _label(_tr("首次启动设置", "FIRST-LAUNCH SETUP"), _scaled(22), Color("#f5ead0")) heading.name = "FirstLaunchHeading" heading.add_theme_color_override("font_shadow_color", Color(0.02, 0.06, 0.08, 0.72)) heading.add_theme_constant_override("shadow_offset_x", 1) heading.add_theme_constant_override("shadow_offset_y", 2) first_launch_settings_panel.add_child(heading) var subheading := _label(_tr("这些选项只影响显示与辅助功能。", "These choices affect presentation and accessibility only."), _scaled(12), Color("#f5ead0")) subheading.name = "FirstLaunchSubheading" subheading.add_theme_color_override("font_shadow_color", Color(0.02, 0.06, 0.08, 0.68)) subheading.add_theme_constant_override("shadow_offset_x", 1) subheading.add_theme_constant_override("shadow_offset_y", 1) subheading.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART subheading.clip_text = true subheading.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN subheading.size_flags_vertical = Control.SIZE_SHRINK_BEGIN first_launch_settings_panel.add_child(subheading) var rows := [ {"id": "SetupLanguage", "label": ["语言", "Language"], "icon": "A", "kind": "choice", "callback": Callable(self, "toggle_setup_locale")}, {"id": "SetupSafeArea", "label": ["安全区", "Safe area"], "icon": "S", "kind": "choice", "callback": Callable(self, "cycle_setup_safe_area")}, {"id": "SetupTextScale", "label": ["字体", "Text scale"], "icon": "T", "kind": "choice", "callback": Callable(self, "cycle_setup_text_scale")}, {"id": "SetupReducedMotion", "label": ["减少动画", "Reduced motion"], "icon": "M", "kind": "toggle", "callback": Callable(self, "toggle_setup_reduced_motion")}, {"id": "SetupReducedFlashes", "label": ["减少闪烁", "Reduced flashes"], "icon": "F", "kind": "toggle", "callback": Callable(self, "toggle_setup_reduced_flashes")}, {"id": "SetupCaptions", "label": ["字幕与事件标签", "Captions"], "icon": "C", "kind": "toggle", "callback": Callable(self, "toggle_setup_captions")} ] for row_data in rows: var row := _first_setting_row(row_data) first_launch_settings_panel.add_child(row) var apply := _first_action_button("SetupApply", _tr("应用并进入标题首页", "Apply and continue to Title"), apply_first_launch, FIRST_LAUNCH_APPLY_COMMAND, 58) apply.focus_mode = Control.FOCUS_ALL first_launch_apply = apply first_launch_settings_panel.add_child(apply) first_launch_error = _label("", _scaled(11), Color("#914b32")) first_launch_error.name = "SetupError" first_launch_error.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART first_launch_settings_panel.add_child(first_launch_error) _layout_first_launch() _refresh_first_launch_labels() func _layout_first_launch() -> void: if not is_instance_valid(first_launch_responsive_layout) or not is_instance_valid(first_launch_preview_frame) or not is_instance_valid(first_launch_settings_panel): return var available := size if available.x <= 0.0 or available.y <= 0.0: return var safe_x := clampf(available.x * 0.035, 18.0, 48.0) var safe_y := clampf(available.y * 0.045, 14.0, 40.0) var gap := 16.0 if available.y >= 420.0 else 8.0 var usable_width := available.x - safe_x * 2.0 var settings_width := clampf(usable_width * (0.44 if available.x >= 900.0 else 0.48), 300.0, 520.0) var preview_width := maxf(270.0, usable_width - settings_width - gap) var content_height := available.y - safe_y * 2.0 var preview_height := maxf(190.0, content_height * (0.78 if available.y >= 420.0 else 0.70)) var preview_x := safe_x var preview_y := safe_y + (content_height - preview_height) * 0.38 first_launch_preview_frame.position = Vector2(preview_x, preview_y) first_launch_preview_frame.size = Vector2(preview_width, preview_height) first_launch_preview_image.position = Vector2(18.0, 18.0) first_launch_preview_image.size = Vector2(preview_width - 36.0, preview_height - 36.0) var guide := first_launch_preview_frame.get_node("FirstLaunchSafeGuide") as Control var safe_guide_inset := clampf(24.0 + float(setup_draft.safe_area) * 0.35, 24.0, minf(preview_width, preview_height) * 0.22) guide.position = Vector2(safe_guide_inset, safe_guide_inset) guide.size = Vector2(preview_width - safe_guide_inset * 2.0, preview_height - safe_guide_inset * 2.0) var preview_heading := first_launch_preview_frame.get_node("FirstLaunchPreviewHeading") as Label preview_heading.position = Vector2(26.0, 22.0) preview_heading.size = Vector2(preview_width - 52.0, 26.0) var preview_title := first_launch_preview_frame.get_node("SetupPreviewTitle") as Label var title_plate_rect := Rect2(preview_width * 0.15, preview_height * 0.37, preview_width * 0.70, 48.0) first_launch_preview_title_plate.position = title_plate_rect.position first_launch_preview_title_plate.size = title_plate_rect.size preview_title.position = title_plate_rect.position + Vector2(12.0, 3.0) preview_title.size = title_plate_rect.size - Vector2(24.0, 6.0) var preview_detail := first_launch_preview_frame.get_node("SetupPreviewDetail") as Label var detail_plate_rect := Rect2(preview_width * 0.12, preview_height - 73.0, preview_width * 0.76, 38.0) first_launch_preview_detail_plate.position = detail_plate_rect.position first_launch_preview_detail_plate.size = detail_plate_rect.size preview_detail.position = detail_plate_rect.position + Vector2(46.0, 2.0) preview_detail.size = detail_plate_rect.size - Vector2(92.0, 4.0) first_launch_settings_panel.position = Vector2(preview_x + preview_width + gap, safe_y) first_launch_settings_panel.size = Vector2(settings_width, content_height) var heading := first_launch_settings_panel.get_node("FirstLaunchHeading") as Label heading.position = Vector2(0, 0) heading.size = Vector2(settings_width, 32.0) var subheading := first_launch_settings_panel.get_node("FirstLaunchSubheading") as Label subheading.position = Vector2(0, 32.0) subheading.size = Vector2(settings_width, 30.0) var row_y := 68.0 var row_gap := 6.0 if available.y >= 420.0 else 4.0 var row_height := clampf((content_height - 150.0 - row_gap * 5.0) / 6.0, 48.0, 68.0) var row_names := ["SetupLanguage", "SetupSafeArea", "SetupTextScale", "SetupReducedMotion", "SetupReducedFlashes", "SetupCaptions"] var compact_columns := available.y < 400.0 if compact_columns: row_height = 48.0 var compact_width := (settings_width - row_gap) * 0.5 for index in range(row_names.size()): var compact_row := first_launch_setting_buttons[row_names[index]] as Button compact_row.position = Vector2((index % 2) * (compact_width + row_gap), row_y + floori(index / 2.0) * (row_height + row_gap)) compact_row.size = Vector2(compact_width, row_height) _layout_first_setting_row(compact_row) row_y += 3.0 * (row_height + row_gap) else: for row_data in row_names: var row := first_launch_setting_buttons[row_data] as Button row.position = Vector2(0, row_y) row.size = Vector2(settings_width, row_height) _layout_first_setting_row(row) row_y += row_height + row_gap first_launch_apply.position = Vector2(0, minf(row_y + 6.0, content_height - 58.0)) first_launch_apply.size = Vector2(settings_width, 58.0) first_launch_error.position = Vector2(0, first_launch_apply.position.y + 60.0) first_launch_error.size = Vector2(settings_width, maxf(24.0, content_height - first_launch_error.position.y)) func _refresh_first_launch_labels() -> void: if first_launch_settings_panel == null: return var values := { "SetupLanguage": _tr("简体中文", "English"), "SetupSafeArea": "%d px" % int(setup_draft.safe_area), "SetupTextScale": "%d%%" % int(round(float(setup_draft.text_scale) * 100.0)), "SetupReducedMotion": _tr("开启", "On") if bool(setup_draft.reduced_motion) else _tr("关闭", "Off"), "SetupReducedFlashes": _tr("开启", "On") if bool(setup_draft.reduced_flashes) else _tr("关闭", "Off"), "SetupCaptions": _tr("开启", "On") if bool(setup_draft.captions) else _tr("关闭", "Off") } for node_name in values: var value_label := first_launch_setting_values.get(node_name) as Label if value_label != null: value_label.text = String(values[node_name]) var heading := first_launch_settings_panel.get_node_or_null("FirstLaunchHeading") as Label if heading != null: heading.text = _tr("首次启动设置", "FIRST-LAUNCH SETUP") var subheading := first_launch_settings_panel.get_node_or_null("FirstLaunchSubheading") as Label if subheading != null: subheading.text = _tr("这些选项只影响显示与辅助功能。", "These choices affect presentation and accessibility only.") if first_launch_apply != null: var label := first_launch_apply.get_node_or_null("ButtonLabel") as Label if label != null: label.text = _tr("应用并进入标题首页", "Apply and continue to Title") func _first_setting_row(row_data: Dictionary) -> Button: var node_name := String(row_data.id) var button := Button.new() button.name = node_name button.flat = true button.focus_mode = Control.FOCUS_ALL button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND for style_name in ["normal", "hover", "pressed", "focus", "disabled", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var background := _first_nine_patch("Background", FIRST_LAUNCH_SETTING_ROW, 36, 0) background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) button.add_child(background) var icon := TextureRect.new() icon.name = "Icon" icon.texture = FIRST_LAUNCH_SETTING_ICON icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS icon.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(icon) var icon_label := _label(String(row_data.icon), _scaled(15), Color("#f5ead0")) icon_label.name = "IconLabel" icon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER icon_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER icon_label.mouse_filter = Control.MOUSE_FILTER_IGNORE icon.add_child(icon_label) var label := _label(_tr(String(row_data.label[0]), String(row_data.label[1])), _scaled(13), Color("#202a31")) label.name = "RowLabel" label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) var value_plate := TextureRect.new() value_plate.name = "ValuePlate" value_plate.texture = FIRST_LAUNCH_VALUE_PLATE value_plate.expand_mode = TextureRect.EXPAND_IGNORE_SIZE value_plate.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED value_plate.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS value_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(value_plate) var value_label := _label("", _scaled(12), Color("#202a31")) value_label.name = "ValueLabel" value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER value_label.mouse_filter = Control.MOUSE_FILTER_IGNORE value_plate.add_child(value_label) first_launch_setting_buttons[node_name] = button first_launch_setting_values[node_name] = value_label button.pressed.connect(row_data.callback, CONNECT_DEFERRED) return button func _layout_first_setting_row(button: Button) -> void: var row_size := button.size var icon := button.get_node("Icon") as TextureRect var label := button.get_node("RowLabel") as Label var value_plate := button.get_node("ValuePlate") as TextureRect var icon_size := clampf(row_size.y - 12.0, 32.0, 54.0) var value_width := clampf(row_size.x * 0.30, 68.0, 132.0) icon.position = Vector2(7.0, (row_size.y - icon_size) * 0.5) icon.size = Vector2(icon_size, icon_size) var value_height := maxf(32.0, row_size.y - 12.0) value_plate.position = Vector2(row_size.x - value_width - 8.0, (row_size.y - value_height) * 0.5) value_plate.size = Vector2(value_width, value_height) label.position = Vector2(icon.position.x + icon_size + 6.0, 0.0) label.size = Vector2(maxf(20.0, value_plate.position.x - label.position.x - 5.0), row_size.y) var value_label := value_plate.get_node("ValueLabel") as Label value_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) func _first_action_button(node_name: String, text_value: String, callback: Callable, texture: Texture2D, height: float) -> Button: var button := Button.new() button.name = node_name button.flat = true button.focus_mode = Control.FOCUS_ALL button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND for style_name in ["normal", "hover", "pressed", "focus", "disabled", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var frame := _first_nine_patch("CommandFrame", texture, 60, 0) frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) button.add_child(frame) var label := _label(text_value, _scaled(15), Color("#213744")) label.name = "ButtonLabel" label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) button.custom_minimum_size = Vector2(0, height) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _first_nine_patch(node_name: String, texture: Texture2D, horizontal_margin: int, vertical_margin: int) -> NinePatchRect: var panel := NinePatchRect.new() panel.name = node_name panel.texture = texture panel.patch_margin_left = horizontal_margin panel.patch_margin_right = horizontal_margin panel.patch_margin_top = vertical_margin panel.patch_margin_bottom = vertical_margin panel.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS panel.mouse_filter = Control.MOUSE_FILTER_IGNORE return panel func _first_outline_style(color: Color, width: int, alpha: float) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0, 0, 0, 0) style.border_color = Color(color.r, color.g, color.b, alpha) style.set_border_width_all(width) return style func toggle_setup_locale() -> void: setup_draft.locale = "en" if String(setup_draft.locale) == "zh_CN" else "zh_CN" settings = setup_draft.duplicate(true) show_first_launch() func cycle_setup_safe_area() -> void: var options := SettingsServiceScript.SAFE_AREA_PRESETS var next := (options.find(int(setup_draft.safe_area)) + 1) % options.size() setup_draft.safe_area = options[next] settings = setup_draft.duplicate(true) show_first_launch() func cycle_setup_text_scale() -> void: var options := SettingsServiceScript.TEXT_SCALES var current := 0 for index in range(options.size()): if is_equal_approx(float(setup_draft.text_scale), float(options[index])): current = index setup_draft.text_scale = options[(current + 1) % options.size()] settings = setup_draft.duplicate(true) show_first_launch() func set_setup_reduced_motion(value: bool) -> void: setup_draft.reduced_motion = value _refresh_first_launch_labels() func toggle_setup_reduced_motion() -> void: setup_draft.reduced_motion = not bool(setup_draft.reduced_motion) settings = setup_draft.duplicate(true) show_first_launch() func set_setup_reduced_flashes(value: bool) -> void: setup_draft.reduced_flashes = value _refresh_first_launch_labels() func toggle_setup_reduced_flashes() -> void: setup_draft.reduced_flashes = not bool(setup_draft.reduced_flashes) settings = setup_draft.duplicate(true) show_first_launch() func set_setup_captions(value: bool) -> void: setup_draft.captions = value _refresh_first_launch_labels() func toggle_setup_captions() -> void: setup_draft.captions = not bool(setup_draft.captions) settings = setup_draft.duplicate(true) show_first_launch() func apply_first_launch() -> void: setup_draft.setup_completed = true var saved: Dictionary = settings_service.save_settings(setup_draft) if not saved.ok: var error := first_launch_error if error != null: error.text = _tr("无法保存设置。请重试。", "Settings could not be saved. Retry.") + " [%s]" % String(saved.code) return settings = saved.settings.duplicate(true) show_title() func show_title() -> void: current_screen = "title" title_master_design_canvas = null for child in get_children(): child.free() var world_extension := ColorRect.new() world_extension.name = "TitleMasterWorldExtension" world_extension.color = Color("#182838") world_extension.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) world_extension.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(world_extension) var world_background := TextureRect.new() world_background.name = "TitleWorldBackground" world_background.texture = M52_Q_TITLE_BACKGROUND world_background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE world_background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED world_background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS world_background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) world_background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(world_background) var safe := Control.new() safe.name = "TitleSafeArea" safe.clip_contents = true safe.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(safe) title_master_design_canvas = safe _layout_title_master_canvas() var master := TextureRect.new() master.name = "TitleMasterComposite" master.texture = M52_Q_TITLE_BACKGROUND master.expand_mode = TextureRect.EXPAND_IGNORE_SIZE master.stretch_mode = TextureRect.STRETCH_SCALE master.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS master.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) master.mouse_filter = Control.MOUSE_FILTER_IGNORE # The full-viewport cover layer owns the responsive crop. Keep the named # coordinate-master node for existing route/tests without drawing a second # differently-cropped bitmap over the safe canvas. master.modulate.a = 0.0 safe.add_child(master) var layout := Control.new() layout.name = "TitleLayout" layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) layout.mouse_filter = Control.MOUSE_FILTER_IGNORE safe.add_child(layout) var header := Control.new() header.name = "TitleHeader" _set_normalized_rect(header, Rect2(0.075, 0.095, 0.355, 0.305)) header.mouse_filter = Control.MOUSE_FILTER_IGNORE layout.add_child(header) var identity_surface := Panel.new() identity_surface.name = "TitleIdentityPlaque" identity_surface.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) identity_surface.mouse_filter = Control.MOUSE_FILTER_IGNORE var identity_style := StyleBoxFlat.new() identity_style.bg_color = Color("#f4e1b7") identity_style.border_color = Color(0.35, 0.25, 0.18, 0.72) identity_style.set_border_width_all(1) identity_style.set_corner_radius_all(18) identity_style.shadow_color = Color(0.05, 0.10, 0.12, 0.25) identity_style.shadow_size = 8 identity_style.shadow_offset = Vector2(2, 4) identity_style.anti_aliasing = true identity_surface.add_theme_stylebox_override("panel", identity_style) header.add_child(identity_surface) var identity_ribbon := Panel.new() identity_ribbon.name = "TitleIdentityRibbon" identity_ribbon.anchor_left = 0.035 identity_ribbon.anchor_top = 0.27 identity_ribbon.anchor_right = 0.115 identity_ribbon.anchor_bottom = 0.73 identity_ribbon.mouse_filter = Control.MOUSE_FILTER_IGNORE var ribbon_style := StyleBoxFlat.new() ribbon_style.bg_color = Color("#397681") ribbon_style.set_corner_radius_all(12) ribbon_style.shadow_color = Color(0.05, 0.10, 0.12, 0.18) ribbon_style.shadow_size = 3 ribbon_style.anti_aliasing = true identity_ribbon.add_theme_stylebox_override("panel", ribbon_style) identity_surface.add_child(identity_ribbon) var identity_accent := Panel.new() identity_accent.name = "TitleIdentityAccent" identity_accent.anchor_left = 0.052 identity_accent.anchor_top = 0.38 identity_accent.anchor_right = 0.098 identity_accent.anchor_bottom = 0.62 identity_accent.mouse_filter = Control.MOUSE_FILTER_IGNORE var accent_style := StyleBoxFlat.new() accent_style.bg_color = Color("#e4b362") accent_style.set_corner_radius_all(8) accent_style.anti_aliasing = true identity_accent.add_theme_stylebox_override("panel", accent_style) identity_surface.add_child(identity_accent) var overline := _label(_tr("风路公会 · 远征入口", "WIND-ROAD GUILD"), _scaled(8), Color("#6a4b2d")) overline.name = "TitleOverline" _set_normalized_rect(overline, Rect2(0.10, 0.10, 0.80, 0.16)) overline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER overline.vertical_alignment = VERTICAL_ALIGNMENT_CENTER overline.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(overline) var locale := String(settings.get("locale", "zh_CN")) var identity := _label(_tr("裂脉远征团", "AETHERBOUND GUILD"), _scaled(22 if locale == "zh_CN" else 17), Color("#213744")) identity.name = "TitleIdentity" _set_normalized_rect(identity, Rect2(0.10, 0.32, 0.80, 0.40)) identity.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS identity.add_theme_color_override("font_outline_color", Color("#fff4d6")) identity.add_theme_constant_override("outline_size", 1) identity.add_theme_color_override("font_shadow_color", Color(0.05, 0.10, 0.12, 0.34)) identity.add_theme_constant_override("shadow_offset_x", 1) identity.add_theme_constant_override("shadow_offset_y", 2) identity.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(identity) var chapter := _label(_tr("第一远征纪", "THE FIRST CHRONICLE"), _scaled(8), Color("#6a4b2d")) chapter.name = "TitleChronicle" _set_normalized_rect(chapter, Rect2(0.18, 0.76, 0.64, 0.16)) chapter.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER chapter.vertical_alignment = VERTICAL_ALIGNMENT_CENTER chapter.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(chapter) var dock := Control.new() dock.name = "TitleCommandDock" _set_normalized_rect(dock, Rect2(0.61, 0.44, 0.32, 0.55)) dock.mouse_filter = Control.MOUSE_FILTER_IGNORE layout.add_child(dock) var copy := Control.new() copy.name = "TitleCopy" copy.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) copy.mouse_filter = Control.MOUSE_FILTER_IGNORE layout.add_child(copy) var actions := Control.new() actions.name = "TitleActions" actions.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) actions.mouse_filter = Control.MOUSE_FILTER_IGNORE copy.add_child(actions) var tools := Control.new() tools.name = "TitleTools" tools.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) tools.mouse_filter = Control.MOUSE_FILTER_IGNORE layout.add_child(tools) var save_status := _save_status() var company_result := initial_company_service.load_company() var has_company := bool(company_result.ok) var data_unsafe := save_status == "unsafe" var new_game_detail := ( _tr("远征团已建立 · 可继续进入公会", "Expedition Company established · continue to the Guild") if has_company else _tr("本地数据需要处理", "Local data needs attention") if data_unsafe else _tr("发现可恢复的本地远征记录", "A local expedition record is available") if save_status == "valid" else _tr("创建你的初始远征团", "Create your opening Company") ) var new_game := _title_master_button("TitleNewGame", _tr("开始远征", "New Game"), new_game_detail, open_new_game_boundary, Rect2(0.62, 0.515, 0.23, 0.19), "primary") new_game.disabled = has_company or data_unsafe new_game.tooltip_text = _tr("远征团已建立;继续游戏进入公会", "A Company already exists; Continue opens the Guild") if has_company else _tr("本地数据需要先处理", "Local data needs attention") if new_game.disabled else _tr("建立初始远征团", "Create the initial Company") _set_title_master_button_disabled(new_game, new_game.disabled) actions.add_child(new_game) var continue_reason := ( _tr("本地数据需要处理", "Local data needs attention") if data_unsafe else _tr("暂无可恢复记录", "No resumable expedition") if save_status != "valid" else _tr("进入存档确认", "Review the saved expedition") ) var continue_button := _title_master_button("TitleContinue", _tr("继续游戏", "Continue"), continue_reason, open_continue_boundary, Rect2(0.635, 0.68, 0.18, 0.13), "secondary") var continue_detail := continue_button.get_node("DetailLabel") as Label continue_detail.name = "TitleContinueReason" continue_button.disabled = save_status != "valid" continue_button.tooltip_text = _tr("没有可继续的本地远征记录", "No local expedition is available") if continue_button.disabled else _tr("查看可恢复的远征记录", "Review the resumable expedition") _set_title_master_button_disabled(continue_button, continue_button.disabled) actions.add_child(continue_button) var notice := _label(title_notice, _scaled(7), Color("#914b32")) notice.name = "TitleNotice" _set_normalized_rect(notice, Rect2(0.08, 0.57, 0.84, 0.34)) notice.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER notice.vertical_alignment = VERTICAL_ALIGNMENT_CENTER notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART notice.mouse_filter = Control.MOUSE_FILTER_IGNORE continue_detail.visible = title_notice.is_empty() continue_button.add_child(notice) var summary := new_game.get_node("DetailLabel") as Label summary.name = "TitleSaveSummary" var settings_button := _title_master_button("TitleSettings", _tr("设置", "Settings"), "", show_settings_placeholder, Rect2(0.61, 0.835, 0.14, 0.11), "utility") settings_button.tooltip_text = _tr("设置", "Settings") tools.add_child(settings_button) var credits_button := _title_master_button("TitleCredits", _tr("制作人员", "Credits"), "", show_credits_placeholder, Rect2(0.765, 0.835, 0.14, 0.11), "utility") tools.add_child(credits_button) (new_game if not new_game.disabled else continue_button if not continue_button.disabled else settings_button).call_deferred("grab_focus") func _layout_title_master_canvas() -> void: if not is_instance_valid(title_master_design_canvas): return var available := size if available.x <= 0.0 or available.y <= 0.0: return const DESIGN_ASPECT := 16.0 / 9.0 var canvas_size: Vector2 if available.x / available.y >= DESIGN_ASPECT: canvas_size = Vector2(available.y * DESIGN_ASPECT, available.y) else: canvas_size = Vector2(available.x, available.x / DESIGN_ASPECT) title_master_design_canvas.position = (available - canvas_size) * 0.5 title_master_design_canvas.size = canvas_size func open_new_game_boundary() -> void: var company := initial_company_service.load_company() if company.ok: _open_company_destination(company.company) return if String(company.code) != "COMPANY_MISSING": title_notice = _tr("远征团存档需要处理,未创建新候选。", "Company data needs attention; no new candidates were created.") + " [%s]" % String(company.code) show_title() return show_initial_company() func show_initial_company() -> void: current_screen = "initial_company" for child in get_children(): child.free() initial_company_page = InitialCompanyPage.new() initial_company_page.name = "InitialCompanyPage" initial_company_page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) initial_company_page.back_requested.connect(back_to_title, CONNECT_DEFERRED) initial_company_page.company_committed.connect(show_guild_handoff, CONNECT_DEFERRED) add_child(initial_company_page) initial_company_page.open(initial_company_service, settings) func open_continue_boundary() -> void: var company := initial_company_service.load_company() if company.ok: _open_company_destination(company.company) return if _save_status() != "valid": title_notice = _tr("没有可恢复的本地远征记录。", "No resumable local expedition is available.") else: title_notice = _tr("本地远征记录保持不变;恢复确认属于后续存档页面。", "The local expedition remains unchanged; Resume Review belongs to the later save pages.") show_title() func _open_company_destination(company: Dictionary) -> void: if company.has("active_contract") and not company.active_contract.is_empty(): if company.active_contract.has("battle_state") and not company.active_contract.battle_state.is_empty(): var battle_status := String(company.active_contract.battle_state.get("status", "")) if battle_status == "active": if company.active_contract.battle_state.has("observation") and not Dictionary(company.active_contract.battle_state.observation).is_empty(): show_battle(company, false, true) else: show_battle_opening(company) return if battle_status == "outcome_locked": show_battle(company, false) return if battle_status == "result": show_battle_result(company) return if String(company.active_contract.get("status", "")) == "returned": show_guild_handoff(company) return if company.active_contract.has("formation") and not company.active_contract.formation.is_empty(): var formation: Dictionary = company.active_contract.formation if String(formation.get("status", "draft")) in ["ordered", "ready"]: show_readiness_review(company) else: show_party_line(company) else: show_market_handoff(company) else: show_guild_handoff(company) func show_guild_handoff(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else {} if resolved.is_empty(): var loaded := initial_company_service.load_company() if not loaded.ok: title_notice = _tr("无法读取远征团。", "The Company could not be loaded.") + " [%s]" % String(loaded.code) show_title() return resolved = loaded.company.duplicate(true) guild_contract_service.company_path = initial_company_service.company_path var guild_snapshot: Dictionary = guild_page_controller.snapshot(resolved, String(settings.get("locale", "zh_CN"))) if not bool(guild_snapshot.get("ok", false)): title_notice = _tr("无法读取远征团。", "The Company could not be loaded.") + " [%s]" % String(guild_snapshot.get("code", "COMPANY_REQUIRED")) show_title() return guild_company = resolved guild_view_data = guild_snapshot show_guild_anchor(resolved) return current_screen = "guild_handoff" _build_world_base(true) var frame_safe := MarginContainer.new() frame_safe.name = "GuildHandoffFrameSafeArea" frame_safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) frame_safe.mouse_filter = Control.MOUSE_FILTER_IGNORE var margin := maxi(12, int(settings.get("safe_area", 24))) var vertical_margin := 12 if size.y < 500.0 else margin frame_safe.add_theme_constant_override("margin_left", margin) frame_safe.add_theme_constant_override("margin_right", margin) frame_safe.add_theme_constant_override("margin_top", vertical_margin) frame_safe.add_theme_constant_override("margin_bottom", vertical_margin) add_child(frame_safe) frame_safe.add_child(_entry_chrome("GuildHandoffFrame", "content_scene_frame")) var safe := MarginContainer.new() safe.name = "GuildHandoffSafeArea" safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) safe.add_theme_constant_override("margin_left", margin + 12) safe.add_theme_constant_override("margin_right", margin + 12) safe.add_theme_constant_override("margin_top", vertical_margin + 8) safe.add_theme_constant_override("margin_bottom", vertical_margin + 8) add_child(safe) var layout := VBoxContainer.new() layout.name = "GuildHandoffLayout" layout.add_theme_constant_override("separation", 6) safe.add_child(layout) var header := HBoxContainer.new() header.custom_minimum_size = Vector2(0, 50) layout.add_child(header) var copy := VBoxContainer.new() copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(copy) copy.add_child(_label(_tr("公会大厅", "GUILD HALL"), _scaled(21), Color("#f5ead0"))) var contract_status := String(resolved.get("active_contract", {}).get("status", "")) var active_contract: bool = contract_status in ["market", "battle", "result", "recovery"] var returned_contract: bool = contract_status == "returned" copy.add_child(_label( _tr("首个委托已开始", "FIRST CONTRACT ACTIVE") if active_contract else (_tr("委托已返回 · 成长保留", "CONTRACT RETURNED · GROWTH RETAINED") if returned_contract else _tr("初始远征团已建立", "INITIAL EXPEDITION COMPANY ESTABLISHED")), _scaled(11), Color("#e8d39d") )) var identity := _label( _tr("市场准备", "MARKET READY") if active_contract else (_tr("可继续管理", "READY FOR MANAGEMENT") if returned_contract else _tr("持续远征", "PERSISTENT COMPANY")), _scaled(12), Color("#73b9e7") ) identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER header.add_child(identity) var body := HBoxContainer.new() body.name = "GuildHandoffBody" body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 18) layout.add_child(body) var roster := HBoxContainer.new() roster.name = "GuildRoster" roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL roster.size_flags_stretch_ratio = 1.45 roster.add_theme_constant_override("separation", 12) body.add_child(roster) for index in range(resolved.owned_recruits.size()): roster.add_child(_guild_member_column(index, resolved.owned_recruits[index])) var next := VBoxContainer.new() next.name = "GuildNextActions" next.custom_minimum_size = Vector2(minf(330.0, size.x * 0.36), 0) next.add_theme_constant_override("separation", 6) body.add_child(next) next.add_child(_label(_tr("下一步", "NEXT"), _scaled(10), Color("#e8d39d"))) var next_copy := _label( _tr("风镜初渡已建立。前往市场扩充远征团。", "First Windglass Crossing is active. Enter the Market to expand the Company.") if active_contract else (_tr("委托已主动返回;金币、成员、等级、装备与历史均保留。", "The Contract was returned voluntarily; Coin, members, levels, equipment and history are retained.") if returned_contract else _tr("选择首个委托与难度,然后进入市场。", "Choose the first Contract and difficulty, then enter the Market.")), _scaled(13), Color("#f5ead0") ) next_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(next_copy) var contract := _guild_destination_button( "GuildContractActive" if active_contract else ("GuildContractReturned" if returned_contract else "GuildContractBoard"), "contract", _tr("委托已开始", "Contract Active") if active_contract else (_tr("委托已返回", "Contract Returned") if returned_contract else _tr("委托板", "Contract Board")), func(): _set_guild_notice(_tr("已有进行中的委托。先进入市场。", "A Contract is active. Enter its Market next.")) if active_contract else (_set_guild_notice(_tr("该委托已返回;后续 Contract 创建属于更晚步骤。", "This Contract has returned; creating another Contract belongs to a later step.")) if returned_contract else show_contract_choice()) ) _set_entry_button_disabled(contract, active_contract or returned_contract) contract.visible = not active_contract and not returned_contract contract.tooltip_text = _tr("已有进行中的委托", "A Contract is already active") if active_contract else (_tr("该委托已返回;下一份委托尚未开放", "This Contract has returned; the next Contract is not open yet") if returned_contract else _tr("查看风镜初渡并选择难度", "Review First Windglass Crossing and choose difficulty")) next.add_child(contract) var contract_reason := _label( _tr("已开始:风镜初渡 · %s", "Active: First Windglass Crossing · %s") % _difficulty_name(String(resolved.active_contract.get("difficulty", "standard"))) if active_contract else (_tr("已返回:成长继续保留", "Returned: persistent growth remains available") if returned_contract else _tr("可用:风镜初渡 · 选择难度后确认", "Available: First Windglass Crossing · choose difficulty, then confirm")), _scaled(10), Color("#aebbc2") ) contract_reason.name = "GuildContractReason" contract_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(contract_reason) var market := _guild_destination_button( "GuildMarket" if active_contract else "GuildMarketUnavailable", "market", _tr("进入市场", "Enter Market") if active_contract else _tr("市场", "Market"), func(): show_market_handoff(resolved) if active_contract else _set_guild_notice(_tr("请先选择并确认首个委托。", "Choose and confirm the first Contract before entering the Market.")) ) _set_entry_button_disabled(market, not active_contract) market.visible = active_contract market.tooltip_text = _tr("进入首个委托市场", "Enter the first Contract Market") if active_contract else _tr("先确认首个委托", "Confirm the first Contract first") next.add_child(market) var market_reason := _label( _tr("可用:%d 金币 · 远征团 %d · 队列容量 %d", "Available: %d Coin · Company %d · Line capacity %d") % [int(resolved.active_contract.get("coin", 0)), resolved.owned_recruits.size(), int(resolved.active_contract.get("line_capacity", 4))] if active_contract else (_tr("不可用:委托已返回;成员与成长仍可查看。", "Unavailable: the Contract has returned; members and growth remain viewable.") if returned_contract else _tr("不可用:尚未建立首个委托。", "Unavailable: no first Contract has been created.")), _scaled(10), Color("#aebbc2") ) market_reason.name = "GuildMarketReason" market_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(market_reason) var notice := _label("", _scaled(10), Color("#f0ca75")) notice.name = "GuildNotice" notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(notice) var footer := HBoxContainer.new() footer.name = "GuildHandoffFooter" footer.custom_minimum_size = Vector2(0, 52) footer.add_theme_constant_override("separation", 8) layout.add_child(footer) footer.add_child(_company_button("GuildTitle", _tr("标题首页", "Title"), back_to_title, "company_utility", "back")) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(spacer) footer.add_child(_company_button("GuildRosterButton", _tr("成员", "Roster"), show_roster.bind(resolved), "company_secondary", "inspect")) footer.add_child(_company_button("GuildSettings", _tr("设置", "Settings"), show_settings_from_guild, "company_secondary", "settings")) var focus_target := contract if contract.visible else market if market.visible else footer.get_node("GuildRosterButton") focus_target.call_deferred("grab_focus") func show_guild_anchor(company: Dictionary) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) var view_data: Dictionary = guild_page_controller.snapshot(resolved, String(settings.get("locale", "zh_CN"))) if not bool(view_data.get("ok", false)): title_notice = _tr("无法读取远征团。", "The Company could not be loaded.") + " [%s]" % String(view_data.get("code", "COMPANY_REQUIRED")) show_title() return guild_view_data = view_data.duplicate(true) guild_view_data["notice"] = guild_notice guild_company = resolved current_screen = "guild_handoff" # Guild can be opened directly by a resumed save or a capture fixture, so use # the live Settings locale/scale instead of a stale first-launch draft. setup_draft.locale = String(settings.get("locale", setup_draft.get("locale", "zh_CN"))) setup_draft.text_scale = float(settings.get("text_scale", setup_draft.get("text_scale", 1.0))) for child in get_children(): child.free() var view := GuildPageViewScript.new() view.name = "GuildView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))), "locale": String(setup_draft.get("locale", settings.get("locale", "zh_CN"))), }) view.mount(PageContextScript.new({ "page_id": "guild", "caller_id": "entry", "return_page_id": "title", "return_focus_path": "GuildContractBoard", "metadata": {"company_id": String(resolved.get("company_id", ""))}, })) view.intent_emitted.connect(_on_guild_view_intent, CONNECT_DEFERRED) add_child(view) guild_view = view view.render(guild_view_data) guild_notice = "" func _on_guild_view_intent(intent_id: String, _payload: Dictionary) -> void: match intent_id: "title": back_to_title() "settings": show_settings_from_guild() "roster": show_roster(guild_company) "equipment": guild_notice = _tr("建立委托后可在市场查看装备。", "Create a Contract to review equipment in its Market.") show_guild_anchor(guild_company) "party": open_party_line() "risk": open_risk_board() "contract": show_contract_choice() "market": show_market_handoff(guild_company) func _layout_guild_master_canvas() -> void: if not is_instance_valid(guild_master_design_canvas): return var available := size if available.x <= 0.0 or available.y <= 0.0: return const DESIGN_ASPECT := 16.0 / 9.0 var canvas_size: Vector2 if available.x / available.y >= DESIGN_ASPECT: canvas_size = Vector2(available.y * DESIGN_ASPECT, available.y) else: canvas_size = Vector2(available.x, available.x / DESIGN_ASPECT) guild_master_design_canvas.position = (available - canvas_size) * 0.5 guild_master_design_canvas.size = canvas_size _refresh_guild_typography() _refresh_guild_bottom_nav_geometry() func _refresh_guild_typography() -> void: var label_sizes := { "GuildAnchorTitle": 11, "GuildAnchorStatus": 6, "GuildProgressStatus": 9, "GuildCoinStatus": 9, "GuildAnchorCompanyStatus": 9, "GuildKioskText": 11, "GuildContractReason": 8, "GuildMarketReason": 8, "GuildNotice": 8, "GuildMasterIdentity0": 8, "GuildMasterIdentity1": 8, "GuildTitleCommandLabel": 8, "GuildRosterCommandLabel": 8, "GuildEquipmentCommandLabel": 8, "GuildPartyCommandLabel": 8, "GuildRouteStatusLabel": 8, } for node_name in label_sizes: var label := find_child(String(node_name), true, false) as Label if label != null: label.add_theme_font_size_override("font_size", _guild_text_size(int(label_sizes[node_name]))) if label.has_theme_constant_override("outline_size"): label.add_theme_constant_override("outline_size", maxi(1, _guild_text_size(1))) func _refresh_guild_bottom_nav_geometry() -> void: for index in range(4): var button_names := ["GuildRosterButton", "GuildEquipment", "GuildParty", "GuildRisk"] var button := find_child(button_names[index], true, false) as Button if button != null: _set_guild_bottom_nav_rect(button, index) func _set_guild_bottom_nav_rect(control: Control, index: int) -> void: var canvas_height := guild_master_design_canvas.size.y if is_instance_valid(guild_master_design_canvas) else size.y var target_size := clampf(canvas_height * 0.145, 50.0, 76.0) var gap := clampf(canvas_height * 0.012, 4.0, 8.0) var start_x := clampf(canvas_height * 0.035, 10.0, 25.0) var center_y := minf(canvas_height * 0.945, canvas_height - target_size * 0.5 - 2.0) control.set_anchors_preset(Control.PRESET_TOP_LEFT) control.position = Vector2(start_x + float(index) * (target_size + gap), center_y - target_size * 0.5) control.size = Vector2(target_size, target_size) func _guild_desk_segmented_rail(parent: Control, node_name: String, rect: Rect2, left_texture: Texture2D, center_texture: Texture2D, right_texture: Texture2D, left_fraction: float, right_fraction: float) -> Control: var rail := Control.new() rail.name = node_name _set_normalized_rect(rail, rect) rail.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(rail) var left := TextureRect.new() left.name = "%sLeft" % node_name left.texture = left_texture left.expand_mode = TextureRect.EXPAND_IGNORE_SIZE left.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED left.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS left.anchor_left = 0.0 left.anchor_top = 0.0 left.anchor_right = left_fraction left.anchor_bottom = 1.0 left.offset_left = 0 left.offset_top = 0 left.offset_right = 0 left.offset_bottom = 0 left.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(left) var center := TextureRect.new() center.name = "%sCenter" % node_name center.texture = center_texture center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE center.stretch_mode = TextureRect.STRETCH_SCALE center.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS center.anchor_left = left_fraction center.anchor_right = 1.0 - right_fraction center.anchor_bottom = 1.0 center.offset_left = 0 center.offset_top = 0 center.offset_right = 0 center.offset_bottom = 0 center.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(center) var right := TextureRect.new() right.name = "%sRight" % node_name right.texture = right_texture right.expand_mode = TextureRect.EXPAND_IGNORE_SIZE right.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED right.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS right.anchor_left = 1.0 - right_fraction right.anchor_right = 1.0 right.anchor_bottom = 1.0 right.offset_left = 0 right.offset_top = 0 right.offset_right = 0 right.offset_bottom = 0 right.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(right) return rail func _guild_desk_status_slot(parent: Control, node_name: String, rect: Rect2, value: String, icon_kind: String) -> Label: var slot := Control.new() slot.name = "%sSlot" % node_name _set_normalized_rect(slot, rect) slot.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(slot) var pill := Panel.new() pill.name = "%sPill" % node_name pill.anchor_left = 0.25 pill.anchor_top = 0.24 pill.anchor_right = 0.98 pill.anchor_bottom = 0.76 pill.add_theme_stylebox_override("panel", _flat_style(Color("#f2dfb4"), Color("#9d7839"), 1, 12)) pill.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(pill) var badge := Control.new() badge.name = "%sIcon" % node_name badge.anchor_left = 0.00 badge.anchor_top = 0.02 badge.anchor_right = 0.36 badge.anchor_bottom = 0.98 badge.mouse_filter = Control.MOUSE_FILTER_IGNORE badge.add_child(_entry_chrome("Chrome", "guild_status_icon", icon_kind)) slot.add_child(badge) var label := _label(value, _guild_text_size(8), Color("#2f3130")) label.name = node_name label.anchor_left = 0.36 label.anchor_top = 0.24 label.anchor_right = 0.96 label.anchor_bottom = 0.76 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(label) return label func _guild_desk_icon_button(parent: Control, node_name: String, tooltip: String, icon_kind: String, callback: Callable, rect: Rect2) -> Button: var button := _guild_master_hit_button(node_name, tooltip, callback, rect) var chrome := _entry_chrome("Chrome", "guild_status_icon", icon_kind) chrome.anchor_left = 0.20 button.add_child(chrome) button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND parent.add_child(button) return button func _guild_desk_nav_button(parent: Control, node_name: String, label_name: String, title: String, icon_kind: String, callback: Callable, index: int, disabled: bool) -> Button: var button := _guild_master_hit_button(node_name, title, callback, Rect2()) _set_guild_bottom_nav_rect(button, index) button.disabled = disabled button.add_child(_entry_chrome("Chrome", "guild_nav_icon", icon_kind)) var label := _label(title, _guild_text_size(8), Color("#f8e7b5") if not disabled else Color("#8f9698")) label.name = label_name label.visible = false label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) parent.add_child(button) return button func _guild_desk_slot_art(parent: Control, node_name: String, texture: Texture2D, rect: Rect2) -> TextureRect: var art := TextureRect.new() art.name = node_name art.texture = texture art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED art.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS _set_normalized_rect(art, rect) art.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(art) return art func _show_guild_master_composite_legacy(company: Dictionary) -> void: var resolved := company.duplicate(true) guild_company = resolved current_screen = "guild_handoff" _build_world_base(true) var guild_backdrop := find_child("WorldBackground", true, false) as TextureRect if guild_backdrop != null: guild_backdrop.texture = GUILD_MASTER_BACKGROUND guild_backdrop.stretch_mode = TextureRect.STRETCH_SCALE var root := Control.new() root.name = "GuildAnchorRoot" root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(root) var top_rail := TextureRect.new() top_rail.name = "GuildAnchorTopRail" top_rail.texture = GUILD_MASTER_TOP_RAIL top_rail.expand_mode = TextureRect.EXPAND_IGNORE_SIZE top_rail.stretch_mode = TextureRect.STRETCH_SCALE top_rail.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS top_rail.mouse_filter = Control.MOUSE_FILTER_IGNORE _set_normalized_rect(top_rail, Rect2(0.008, 0.012, 0.976, 0.125)) root.add_child(top_rail) var title := _label(_tr("公会大厅", "GUILD HALL"), _scaled(17), Color("#fff4d6")) title.name = "GuildAnchorTitle" _set_normalized_rect(title, Rect2(0.13, 0.025, 0.20, 0.06)) root.add_child(title) var status := _label(_tr("持续远征", "PERSISTENT COMPANY"), _scaled(9), Color("#a9d9ef")) status.name = "GuildAnchorStatus" _set_normalized_rect(status, Rect2(0.132, 0.080, 0.19, 0.035)) root.add_child(status) var company_status := _label(_tr("成员 %d", "%d MEMBERS") % resolved.owned_recruits.size(), _scaled(13), Color("#f0ca75")) company_status.name = "GuildAnchorCompanyStatus" _set_normalized_rect(company_status, Rect2(0.78, 0.050, 0.13, 0.06)) company_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT root.add_child(company_status) var body := Control.new() body.name = "GuildHandoffBody" body.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) body.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(body) var roster := Control.new() roster.name = "GuildRoster" roster.anchor_right = 1.0 roster.anchor_bottom = 1.0 roster.mouse_filter = Control.MOUSE_FILTER_IGNORE body.add_child(roster) for index in range(resolved.owned_recruits.size()): var anchor_texture: Texture2D = GUILD_MASTER_BELLKEEPER if index == 0 else GUILD_MASTER_SCOUT if index == 1 else null var actor_rect := Rect2(0.235, 0.385, 0.19, 0.53) if index == 0 else Rect2(0.405, 0.405, 0.18, 0.51) if index == 1 else Rect2(0.20 + index * 0.13, 0.43, 0.14, 0.42) roster.add_child(_guild_master_member(index, resolved.owned_recruits[index], anchor_texture, actor_rect)) var next := Control.new() next.name = "GuildNextActions" next.anchor_right = 1.0 next.anchor_bottom = 1.0 next.mouse_filter = Control.MOUSE_FILTER_IGNORE body.add_child(next) var active_contract: bool = String(resolved.get("active_contract", {}).get("status", "")) in ["market", "battle", "result", "recovery"] var returned_contract: bool = String(resolved.get("active_contract", {}).get("status", "")) == "returned" var next_label := _label(_tr("下一步", "NEXT ACTION"), _scaled(10), Color("#e8d39d")) next_label.name = "GuildNextHeading" _set_normalized_rect(next_label, Rect2(0.625, 0.275, 0.27, 0.045)) next_label.visible = false next.add_child(next_label) var next_copy := _label( _tr("前往市场扩充远征团。", "Enter the Market to expand the Company.") if active_contract else _tr("选择首个委托与难度。", "Choose the first Contract and difficulty."), _scaled(11), Color("#fff4d6") ) next_copy.name = "GuildNextCopy" _set_normalized_rect(next_copy, Rect2(0.625, 0.315, 0.28, 0.07)) next_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next_copy.visible = false next.add_child(next_copy) var contract := _guild_master_kiosk_button( "GuildContractActive" if active_contract else "GuildContractBoard", "contract", _tr("委托已开始", "Contract Active") if active_contract else _tr("委托板", "Contract Board"), func(): _set_guild_notice(_tr("委托已开始,请进入市场。", "The Contract is active; enter its Market.")) if active_contract else show_contract_choice(), Rect2(0.625, 0.300, 0.31, 0.53) ) _set_entry_button_disabled(contract, active_contract or returned_contract) contract.visible = not active_contract and not returned_contract next.add_child(contract) var contract_reason := _label( _tr("已开始:风镜初渡", "Active: First Windglass Crossing") if active_contract else _tr("可用:选择难度后确认", "Available: choose a difficulty, then confirm"), _scaled(10), Color("#e8d39d") ) contract_reason.name = "GuildContractReason" _set_normalized_rect(contract_reason, Rect2(0.645, 0.765, 0.28, 0.05)) contract_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(contract_reason) var market := _guild_master_kiosk_button( "GuildMarket" if active_contract else "GuildMarketUnavailable", "market", _tr("进入市场", "Enter Market") if active_contract else _tr("市场", "Market"), func(): show_market_handoff(resolved) if active_contract else _set_guild_notice(_tr("请先确认首个委托。", "Confirm the first Contract first.")), Rect2(0.625, 0.300, 0.31, 0.53) ) _set_entry_button_disabled(market, not active_contract) market.visible = active_contract next.add_child(market) var market_reason := _label( _tr("可用:市场准备", "Available: Market preparation") if active_contract else _tr("不可用:尚未建立首个委托。", "Unavailable: no first Contract has been created."), _scaled(10), Color("#e8d39d") ) market_reason.name = "GuildMarketReason" _set_normalized_rect(market_reason, Rect2(0.645, 0.815, 0.28, 0.05)) market_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(market_reason) var notice := _label("", _scaled(10), Color("#f0ca75")) notice.name = "GuildNotice" _set_normalized_rect(notice, Rect2(0.645, 0.865, 0.28, 0.04)) notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART next.add_child(notice) var bottom_rail := TextureRect.new() bottom_rail.name = "GuildAnchorBottomRail" bottom_rail.texture = GUILD_MASTER_BOTTOM_RAIL bottom_rail.expand_mode = TextureRect.EXPAND_IGNORE_SIZE bottom_rail.stretch_mode = TextureRect.STRETCH_SCALE bottom_rail.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS bottom_rail.mouse_filter = Control.MOUSE_FILTER_IGNORE _set_normalized_rect(bottom_rail, Rect2(0.008, 0.845, 0.976, 0.15)) root.add_child(bottom_rail) var footer := Control.new() footer.name = "GuildHandoffFooter" footer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) footer.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(footer) var back := _guild_master_hit_button("GuildTitle", _tr("返回标题", "Back to Title"), back_to_title, Rect2(0.035, 0.875, 0.075, 0.105)) footer.add_child(back) var roster_button := _guild_master_hit_button("GuildRosterButton", _tr("成员", "Roster"), show_roster.bind(resolved), Rect2(0.105, 0.875, 0.075, 0.105)) footer.add_child(roster_button) var settings_button := _guild_master_hit_button("GuildSettings", _tr("设置", "Settings"), show_settings_from_guild, Rect2(0.175, 0.875, 0.075, 0.105)) footer.add_child(settings_button) (contract if contract.visible else market if market.visible else roster_button).call_deferred("grab_focus") func show_roster(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else guild_company.duplicate(true) if resolved.is_empty(): var loaded := initial_company_service.load_company() if not loaded.ok: show_title() return resolved = loaded.company.duplicate(true) guild_company = resolved var view_data: Dictionary = company_page_controller.roster_snapshot(resolved, roster_selected_recruit_id) if not bool(view_data.get("ok", false)): show_guild_handoff(resolved) return view_data = _prepare_company_page_view_data(view_data) roster_selected_recruit_id = String(view_data.get("selected_recruit_id", "")) current_screen = "roster" for child in get_children(): child.free() var view := CompanyRosterPageViewScript.new() view.name = "CompanyRosterView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))), "locale": String(setup_draft.get("locale", settings.get("locale", "zh_CN"))), }) view.mount(PageContextScript.new({ "page_id": "roster", "caller_id": "guild", "return_page_id": "guild_handoff", "return_focus_path": "GuildRosterButton", "metadata": {"company_id": String(resolved.get("company_id", ""))}, })) view.intent_emitted.connect(_on_company_roster_view_intent, CONNECT_DEFERRED) add_child(view) company_roster_view = view view.render(view_data) func _on_company_roster_view_intent(intent_id: String, payload: Dictionary) -> void: var recruit_id := String(payload.get("recruit_id", "")) match intent_id: "select_recruit": roster_selected_recruit_id = recruit_id show_roster(guild_company) "open_detail": show_recruit_detail(recruit_id) "dismiss": open_dismissal_review(recruit_id) "back": back_to_guild() func show_recruit_detail(recruit_id: String) -> void: var loaded: Dictionary = initial_company_service.load_company() if not loaded.ok: show_title() return var view_data: Dictionary = company_page_controller.recruit_detail_snapshot(loaded.company, recruit_id) if not bool(view_data.get("ok", false)): roster_selected_recruit_id = "" show_roster(loaded.company) return view_data = _prepare_company_page_view_data(view_data) guild_company = loaded.company.duplicate(true) roster_selected_recruit_id = recruit_id current_screen = "recruit_detail" for child in get_children(): child.free() var view := RecruitDetailPageViewScript.new() view.name = "RecruitDetailView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))), "locale": String(setup_draft.get("locale", settings.get("locale", "zh_CN"))), }) view.mount(PageContextScript.new({ "page_id": "recruit_detail", "caller_id": "roster", "return_page_id": "roster", "return_focus_path": "RosterOpenDetail", "metadata": {"company_id": String(guild_company.get("company_id", "")), "recruit_id": recruit_id}, })) view.intent_emitted.connect(_on_recruit_detail_view_intent, CONNECT_DEFERRED) add_child(view) recruit_detail_view = view view.render(view_data) func _on_recruit_detail_view_intent(intent_id: String, payload: Dictionary) -> void: var recruit_id := String(payload.get("recruit_id", roster_selected_recruit_id)) match intent_id: "dismiss": open_dismissal_review(recruit_id) "back": show_roster(guild_company) func _prepare_company_page_view_data(source: Dictionary) -> Dictionary: var prepared := source.duplicate(true) prepared["locale"] = String(setup_draft.get("locale", settings.get("locale", "zh_CN"))) var members: Array = Array(prepared.get("members", [])) for index in range(members.size()): var member: Dictionary = Dictionary(members[index]).duplicate(true) member["equipped_item_display_name"] = _recruit_item_name(member) members[index] = member prepared["members"] = members for key in ["selected", "recruit"]: var recruit: Dictionary = Dictionary(prepared.get(key, {})).duplicate(true) if recruit.is_empty(): continue recruit["equipped_item_display_name"] = _recruit_item_name(recruit) prepared[key] = recruit return prepared func open_dismissal_review(recruit_id: String) -> void: var loaded := initial_company_service.load_company() if not loaded.ok: show_title() return var recruit := _recruit_by_id(loaded.company, recruit_id) if recruit.is_empty(): roster_selected_recruit_id = "" show_roster(loaded.company) return guild_company = loaded.company.duplicate(true) dismissal_recruit_id = recruit_id dismissal_return_screen = current_screen if current_screen in ["roster", "recruit_detail"] else "roster" current_screen = "dismissal_review" _build_world_base() var frame_safe := _framed_page_safe("DismissalReview") frame_safe.add_child(_entry_chrome("DismissalReviewFrame", "initial_company_scene_frame")) var safe := _content_page_safe("DismissalReview") var layout := VBoxContainer.new() layout.name = "DismissalReviewLayout" layout.add_theme_constant_override("separation", 6) safe.add_child(layout) var header := HBoxContainer.new() header.custom_minimum_size = Vector2(0, 48 if size.y < 500.0 else 56) layout.add_child(header) var heading := VBoxContainer.new() heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(heading) heading.add_child(_label(_tr("解雇审查", "DISMISSAL REVIEW"), _scaled(21), Color("#f5ead0"))) heading.add_child(_label(_recruit_name(recruit), _scaled(13), Color("#e8d39d"))) var eligibility := _label(_tr("可执行 · 当前成员 %d", "ELIGIBLE · %d LIVE MEMBERS") % loaded.company.owned_recruits.size(), _scaled(11), Color("#9ed7aa")) eligibility.vertical_alignment = VERTICAL_ALIGNMENT_CENTER header.add_child(eligibility) var body := HBoxContainer.new() body.name = "DismissalReviewBody" body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 18) layout.add_child(body) var portrait_stage := Control.new() portrait_stage.name = "DismissalReviewPortrait" portrait_stage.size_flags_horizontal = Control.SIZE_EXPAND_FILL portrait_stage.size_flags_stretch_ratio = 0.72 body.add_child(portrait_stage) var portrait := TextureRect.new() portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE portrait_stage.add_child(portrait) var facts := VBoxContainer.new() facts.name = "DismissalReviewFacts" facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts.add_theme_constant_override("separation", 6) body.add_child(facts) facts.add_child(_label(_tr("将离开当前 Company", "LEAVES THE CURRENT COMPANY"), _scaled(11), Color("#e8d39d"))) _add_review_detail(facts, "DismissalReviewProfession", _tr("职业", "PROFESSION"), "%s · %s" % [_recruit_profession_name(recruit), String(recruit.profession_job)]) _add_review_detail(facts, "DismissalReviewTrait", _tr("特质", "TRAIT"), _recruit_trait_name(recruit)) _add_review_detail(facts, "DismissalReviewEquipment", _tr("装备归还", "EQUIPMENT RETURN"), _tr("%s 将回到库存", "%s returns to inventory") % _recruit_item_name(recruit)) _add_review_detail(facts, "DismissalReviewGrowth", _tr("保留成长", "GROWTH RETAINED"), _tr("等级 %d · XP %d · 不扣金币", "Level %d · XP %d · no Coin refund or charge") % [int(recruit.level), int(recruit.xp)]) var consequence := _label(_tr("解雇不可撤销;至少保留一名成员。当前阶段必须仍是 Market,已锁定的队列不可解雇。", "Dismissal is irreversible; at least one member must remain. The current phase must be Market, and a locked Line cannot dismiss members."), _scaled(11), Color("#f0ca75")) consequence.name = "DismissalReviewConsequence" consequence.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART consequence.size_flags_vertical = Control.SIZE_EXPAND_FILL facts.add_child(consequence) var notice := _label(dismissal_notice, _scaled(10), Color("#ffd4d4")) notice.name = "DismissalReviewNotice" notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.add_child(notice) var footer := HBoxContainer.new() footer.name = "DismissalReviewFooter" footer.custom_minimum_size = Vector2(0, 46 if size.y < 500.0 else 52) footer.add_theme_constant_override("separation", 8) layout.add_child(footer) var cancel := _company_button("DismissalReviewCancel", _tr("取消", "Cancel"), cancel_dismissal_review, "company_utility", "back") cancel.custom_minimum_size = Vector2(170 if size.y < 500.0 else 190, footer.custom_minimum_size.y) footer.add_child(cancel) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(spacer) var confirm := _company_button("DismissalReviewConfirm", _tr("确认解雇", "Confirm Dismissal"), commit_dismissal, "company_primary", "confirm") confirm.custom_minimum_size = Vector2(170 if size.y < 500.0 else 190, footer.custom_minimum_size.y) confirm.tooltip_text = _tr("移除该成员,装备归还库存,金币不变", "Remove this member, return equipment to inventory and leave Coin unchanged") footer.add_child(confirm) confirm.call_deferred("grab_focus") func cancel_dismissal_review() -> void: dismissal_notice = "" if dismissal_return_screen == "recruit_detail": show_recruit_detail(dismissal_recruit_id) else: show_roster(guild_company) func commit_dismissal() -> void: if dismissal_recruit_id.is_empty(): return guild_contract_service.company_path = initial_company_service.company_path var operation_id := ("%s|dismiss|%s" % [String(guild_company.get("company_id", "")), dismissal_recruit_id]).sha256_text().substr(0, 32) var result := guild_contract_service.dismiss_recruit(operation_id, dismissal_recruit_id) if not result.ok: dismissal_notice = _dismissal_error_text(String(result.get("code", "DISMISS_FAILED"))) open_dismissal_review(dismissal_recruit_id) return dismissal_notice = "" guild_company = result.company.duplicate(true) if roster_selected_recruit_id == dismissal_recruit_id: roster_selected_recruit_id = String(guild_company.owned_recruits[0].recruit_id) if not guild_company.owned_recruits.is_empty() else "" dismissal_recruit_id = "" show_roster(guild_company) func open_contract_return_review() -> void: var loaded := initial_company_service.load_company() if not loaded.ok: show_title() return var contract: Dictionary = loaded.company.get("active_contract", {}) var return_state: Dictionary = market_page_controller.return_command_state(contract) if not bool(return_state.get("enabled", false)): var reason := String(return_state.get("reason", "market_required")) var code := "CONTRACT_RETURN_FORMATION_LOCKED" if reason == "formation_locked" else "CONTRACT_RETURN_MARKET_REQUIRED" var notice := _contract_return_error_text(code) contract_return_notice = "" if String(contract.get("status", "")) == "market": market_notice = notice show_market(loaded.company) else: guild_notice = notice show_guild_handoff(loaded.company) return guild_company = loaded.company.duplicate(true) market_notice = "" current_screen = "contract_return_review" _build_world_base() var frame_safe := _framed_page_safe("ContractReturnReview") frame_safe.add_child(_entry_chrome("ContractReturnReviewFrame", "initial_company_scene_frame")) var safe := _content_page_safe("ContractReturnReview") var layout := VBoxContainer.new() layout.name = "ContractReturnReviewLayout" layout.add_theme_constant_override("separation", 6) safe.add_child(layout) var header := HBoxContainer.new() header.custom_minimum_size = Vector2(0, 48 if size.y < 500.0 else 56) layout.add_child(header) var heading := VBoxContainer.new() heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(heading) heading.add_child(_label(_tr("主动返回委托", "RETURN CONTRACT"), _scaled(21), Color("#f5ead0"))) heading.add_child(_label(_tr("风镜初渡 · 保留 Company 成长", "FIRST WINDGLASS CROSSING · RETAIN COMPANY GROWTH"), _scaled(11), Color("#e8d39d"))) var status := _label(_tr("可确认", "READY TO CONFIRM"), _scaled(11), Color("#9ed7aa")) status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER header.add_child(status) var body := HBoxContainer.new() body.name = "ContractReturnReviewBody" body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 18) layout.add_child(body) var scene := VBoxContainer.new() scene.name = "ContractReturnCompanyScene" scene.size_flags_horizontal = Control.SIZE_EXPAND_FILL scene.size_flags_stretch_ratio = 1.2 scene.add_theme_constant_override("separation", 2) body.add_child(scene) scene.add_child(_label(_tr("当前远征团", "CURRENT COMPANY"), _scaled(11), Color("#e8d39d"))) for index in range(guild_company.owned_recruits.size()): var recruit: Dictionary = guild_company.owned_recruits[index] var line := HBoxContainer.new() line.name = "ContractReturnMember%d" % index line.custom_minimum_size = Vector2(0, 40 if size.y < 380.0 else (56 if size.y < 500.0 else 68)) var portrait := TextureRect.new() portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.custom_minimum_size = Vector2(46 if size.y < 380.0 else 58, 40 if size.y < 380.0 else (56 if size.y < 500.0 else 68)) portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE line.add_child(portrait) line.add_child(_label("%s\n%s · Lv %d · XP %d" % [_recruit_name(recruit), _recruit_profession_name(recruit), int(recruit.level), int(recruit.xp)], _scaled(11), Color("#f5ead0"))) scene.add_child(line) var facts := VBoxContainer.new() facts.name = "ContractReturnFacts" facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts.add_theme_constant_override("separation", 6) body.add_child(facts) facts.add_child(_label(_tr("这是自愿退出,不是 Recruit 解雇。", "VOLUNTARY WITHDRAWAL, NOT RECRUIT DISMISSAL"), _scaled(11), Color("#f0ca75"))) facts.add_child(_label(_tr("保留金币:%d", "Coin retained: %d") % int(contract.coin), _scaled(13), Color("#f5ead0"))) facts.add_child(_label(_tr("成员:%d · 等级/XP、职业、特质、装备、历史全部保留。", "Members: %d · levels/XP, Professions, Traits, equipment and history are all retained.") % guild_company.owned_recruits.size(), _scaled(12), Color("#9ed7aa"))) var warning := _label(_tr("返回后当前委托状态变为 returned,不能再次进入这份 Market;下一份 Contract 属于更晚步骤。", "After returning, this Contract becomes returned and cannot re-open its Market; a later Contract belongs to a later step."), _scaled(11), Color("#e8d39d")) warning.name = "ContractReturnWarning" warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART warning.size_flags_vertical = Control.SIZE_EXPAND_FILL facts.add_child(warning) var notice := _label(contract_return_notice, _scaled(10), Color("#ffd4d4")) notice.name = "ContractReturnNotice" notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.add_child(notice) for fact_node in facts.get_children(): if fact_node is Label: fact_node.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART fact_node.size_flags_horizontal = Control.SIZE_EXPAND_FILL var footer := HBoxContainer.new() footer.name = "ContractReturnReviewFooter" footer.custom_minimum_size = Vector2(0, 46 if size.y < 500.0 else 52) footer.add_theme_constant_override("separation", 8) layout.add_child(footer) var cancel := _company_button("ContractReturnCancel", _tr("取消", "Cancel"), cancel_contract_return, "company_utility", "back") cancel.custom_minimum_size = Vector2(170 if size.y < 500.0 else 190, footer.custom_minimum_size.y) footer.add_child(cancel) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(spacer) var confirm := _company_button("ContractReturnConfirm", _tr("确认返回公会", "Confirm Return to Guild"), commit_contract_return, "company_primary", "confirm") confirm.custom_minimum_size = Vector2(170 if size.y < 500.0 else 190, footer.custom_minimum_size.y) confirm.tooltip_text = _tr("保留全部 Company 成长并返回公会", "Retain all Company growth and return to Guild") footer.add_child(confirm) cancel.call_deferred("grab_focus") func cancel_contract_return() -> void: contract_return_notice = "" show_market(guild_company) func commit_contract_return() -> void: guild_contract_service.company_path = initial_company_service.company_path var operation_id := ("%s|return-contract|%s" % [String(guild_company.get("company_id", "")), String(guild_company.get("active_contract", {}).get("run_id", ""))]).sha256_text().substr(0, 32) var result := guild_contract_service.return_contract(operation_id) if not result.ok: contract_return_notice = _contract_return_error_text(String(result.get("code", "CONTRACT_RETURN_FAILED"))) open_contract_return_review() return contract_return_notice = "" guild_company = result.company.duplicate(true) show_guild_handoff(guild_company) func show_contract_choice() -> void: var loaded := initial_company_service.load_company() if not loaded.ok: contract_notice = _tr("无法读取远征团。", "The Company could not be loaded.") + " [%s]" % String(loaded.code) show_guild_handoff() return if not loaded.company.active_contract.is_empty(): show_market_handoff(loaded.company) return var choice_data: Dictionary = contract_choice_page_controller.snapshot( loaded.company, contract_difficulty, String(settings.get("locale", "zh_CN")), ) if not choice_data.get("ok", false): contract_notice = _contract_choice_guard_text(String(choice_data.get("code", "COMPANY_REQUIRED"))) show_guild_handoff(loaded.company) return contract_choice_view_data = choice_data.duplicate(true) contract_choice_view_data["notice"] = contract_notice contract_difficulty = String(choice_data.get("selected_difficulty", "standard")) guild_company = loaded.company.duplicate(true) current_screen = "contract_choice" for child in get_children(): child.free() var view := ContractChoicePageViewScript.new() view.name = "ContractChoiceView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))), "locale": String(setup_draft.get("locale", settings.get("locale", "zh_CN"))), }) view.mount(PageContextScript.new({ "page_id": "contract_choice", "caller_id": "guild", "return_page_id": "guild_handoff", "return_focus_path": "GuildContractBoard", "metadata": {"company_id": String(guild_company.get("company_id", ""))}, })) view.intent_emitted.connect(_on_contract_choice_view_intent, CONNECT_DEFERRED) add_child(view) contract_choice_view = view view.render(contract_choice_view_data) func _on_contract_choice_view_intent(intent_id: String, payload: Dictionary) -> void: match intent_id: "select_difficulty": select_contract_difficulty(String(payload.get("difficulty", ""))) "confirm": confirm_contract_choice() "back": back_to_guild() func select_contract_difficulty(value: String) -> void: if not contract_choice_page_controller.is_valid_difficulty(value): return contract_difficulty = contract_choice_page_controller.normalize_difficulty(value) contract_notice = "" show_contract_choice() func confirm_contract_choice() -> void: var confirm_state: Dictionary = contract_choice_page_controller.confirm_state(guild_company, contract_difficulty) if not bool(confirm_state.get("eligible", false)): contract_notice = _contract_choice_guard_text(String(confirm_state.get("reason", "confirm_blocked"))) show_contract_choice() return guild_contract_service.company_path = initial_company_service.company_path var operation_id := ("%s|first-contract|%s" % [String(guild_company.get("company_id", "")), contract_difficulty]).sha256_text().substr(0, 32) var created: Dictionary = guild_contract_service.create_contract(operation_id, GuildContractService.FIRST_CONTRACT_ID, contract_difficulty) if not created.ok: contract_notice = _contract_error_text(String(created.get("code", "CONTRACT_CREATE_FAILED"))) show_contract_choice() return contract_notice = "" guild_company = created.company.duplicate(true) show_market_handoff(guild_company) func show_market_handoff(company := {}) -> void: show_market(company) func show_market(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else {} if resolved.is_empty(): var loaded := initial_company_service.load_company() if not loaded.ok: title_notice = _tr("无法读取远征团。", "The Company could not be loaded.") + " [%s]" % String(loaded.code) show_title() return resolved = loaded.company.duplicate(true) if not resolved.has("active_contract") or resolved.active_contract.is_empty(): show_guild_handoff(resolved) return guild_company = resolved var locale := String(settings.get("locale", "zh_CN")) var view_data: Dictionary = market_page_controller.snapshot( resolved, market_category, market_selected_kind, market_selected_offer_id, locale, market_content, ) if not view_data.get("ok", false): market_view_data = {} market_notice = _tr("无法读取稳定货单。", "The stable Market ledger could not be read.") show_guild_handoff(resolved) return market_view_data = view_data.duplicate(true) market_view_data["notice"] = market_notice market_category = String(view_data.get("category", "recruit")) market_selected_kind = String(view_data.get("selected_kind", "")) market_selected_offer_id = String(view_data.get("selected_offer_id", "")) current_screen = "market" for child in get_children(): child.free() var view := MarketPageViewScript.new() view.name = "MarketView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))), "locale": String(setup_draft.get("locale", settings.get("locale", "zh_CN"))), }) view.mount(PageContextScript.new({ "page_id": "market", "caller_id": "guild", "return_page_id": "guild", "return_focus_path": "MarketSettings", "metadata": { "company_id": String(resolved.get("company_id", "")), "selected_kind": market_selected_kind, "selected_offer_id": market_selected_offer_id, }, })) view.intent_emitted.connect(_on_market_view_intent, CONNECT_DEFERRED) add_child(view) market_view = view view.render(market_view_data) func _on_market_view_intent(intent_id: String, payload: Dictionary) -> void: match intent_id: "category": set_market_category(String(payload.get("category", ""))) "select_offer": select_market_offer(String(payload.get("kind", "")), String(payload.get("offer_id", ""))) "recruit_review": open_recruit_review() "equipment_review": open_equipment_review() "route": show_market(guild_company) "return_contract": open_contract_return_review() "back": back_to_guild() "settings": _market_open_settings() func _market_open_settings() -> void: settings_caller = "market" show_settings() func open_party_line() -> void: show_party_line() func show_party_line(company := {}) -> void: formation_risk_service.company_path = initial_company_service.company_path readiness_view_data = {} var begun: Dictionary = formation_risk_service.begin_formation() if not begun.ok: market_notice = _formation_error_text(String(begun.get("code", "FORMATION_FAILED"))) show_market(company) return var resolved: Dictionary = begun.company.duplicate(true) var formation: Dictionary = resolved.active_contract.formation if party_draft_order.is_empty() or not _same_string_members(party_draft_order, formation.base_front_to_rear_ids): party_draft_order.assign(formation.saved_front_to_rear_ids if not formation.saved_front_to_rear_ids.is_empty() else formation.draft_front_to_rear_ids) party_undo_order.clear() party_selected_recruit_id = "" party_notice = "" var previewed: Dictionary = formation_risk_service.preview_order(party_draft_order) if not previewed.ok: party_draft_order.assign(formation.base_front_to_rear_ids) previewed = formation_risk_service.preview_order(party_draft_order) party_preview = previewed var party_snapshot: Dictionary = party_line_page_controller.snapshot( resolved, formation, party_draft_order, party_selected_recruit_id, previewed, party_undo_order, String(settings.get("locale", "zh_CN")) ) if not bool(party_snapshot.get("ok", false)): party_notice = _formation_error_text(String(party_snapshot.get("code", "FORMATION_FAILED"))) show_market(company) return party_view_data = party_snapshot guild_company = resolved current_screen = "party_line" for child in get_children(): child.free() var view := PartyLinePageViewScript.new() view.name = "PartyLineView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "party_line", "caller_id": "guild", "return_page_id": "guild", "return_focus_path": "PartyBack", "metadata": { "company_id": String(resolved.get("company_id", "")), "selected_recruit_id": party_selected_recruit_id, }, })) view.intent_emitted.connect(_on_party_line_view_intent, CONNECT_DEFERRED) add_child(view) party_line_view = view view.render(party_snapshot) func _on_party_line_view_intent(intent_id: String, payload: Dictionary) -> void: match intent_id: "select_member": select_party_member(String(payload.get("recruit_id", ""))) "undo": undo_party_swap() "reset": reset_party_order() "save": save_party_order() "back": cancel_party_line() func select_party_member(recruit_id: String) -> void: var position := party_draft_order.find(recruit_id) if position < 0: party_notice = _tr("该成员已不在当前队列。", "That member is no longer in the current line.") show_party_line(guild_company) return if party_selected_recruit_id.is_empty(): party_selected_recruit_id = recruit_id party_notice = _tr("已选择 %s;再点一名成员交换位置。", "%s selected; choose another member to swap positions.") % _recruit_name(_recruit_by_id(guild_company, recruit_id)) elif party_selected_recruit_id == recruit_id: party_selected_recruit_id = "" party_notice = _tr("已取消位置选择。", "Position selection cleared.") else: var from_position := party_draft_order.find(party_selected_recruit_id) if from_position < 0: party_selected_recruit_id = "" party_notice = _tr("原选择已失效,请重试。", "The previous selection became stale; try again.") else: party_undo_order.assign(party_draft_order) var moved_id := String(party_selected_recruit_id) party_draft_order[from_position] = recruit_id party_draft_order[position] = moved_id party_selected_recruit_id = moved_id party_notice = _tr("已预览交换:%d 号 ↔ %d 号。首要目标与保护关系已更新。", "Previewed swap: position %d <-> %d. Primary target and protection are updated.") % [from_position, position] show_party_line(guild_company) func undo_party_swap() -> void: if party_undo_order.is_empty(): party_notice = _tr("还没有可撤销的位置交换。", "No position swap is available to undo.") else: var current := party_draft_order.duplicate() party_draft_order.assign(party_undo_order) party_undo_order.assign(current) party_selected_recruit_id = "" party_notice = _tr("已撤销上一次位置交换。", "The last position swap was undone.") show_party_line(guild_company) func reset_party_order() -> void: formation_risk_service.company_path = initial_company_service.company_path var reset: Dictionary = formation_risk_service.reset_order() if not reset.ok: party_notice = _formation_error_text(String(reset.get("code", "FORMATION_RESET_FAILED"))) else: party_undo_order.assign(party_draft_order) party_draft_order.assign(reset.front_to_rear_ids) party_selected_recruit_id = "" party_notice = _tr("已恢复系统初始顺序。", "Automatic starting order restored.") show_party_line(guild_company) func cancel_party_line() -> void: party_draft_order.clear() party_undo_order.clear() party_selected_recruit_id = "" party_preview = {} party_view_data = {} readiness_view_data = {} party_notice = "" back_to_guild() func save_party_order() -> void: formation_risk_service.company_path = initial_company_service.company_path var order_hash := JSON.stringify(_canonical_value(party_draft_order)).sha256_text() var operation_id := ("%s|save-party-order|%s" % [String(guild_company.company_id), order_hash]).sha256_text().substr(0, 32) var saved: Dictionary = formation_risk_service.save_order(operation_id, party_draft_order) if not saved.ok: party_notice = _formation_error_text(String(saved.get("code", "FORMATION_SAVE_FAILED"))) show_party_line(guild_company) return guild_company = saved.company.duplicate(true) party_notice = "" show_readiness_review(guild_company) func show_readiness_review(company := {}) -> void: formation_risk_service.company_path = initial_company_service.company_path var loaded: Dictionary = formation_risk_service.load_company() if not loaded.ok: party_notice = _formation_error_text(String(loaded.get("code", "READINESS_LOAD_FAILED"))) show_party_line(company) return var resolved: Dictionary = loaded.company.duplicate(true) var formation: Dictionary = resolved.active_contract.get("formation", {}) if formation.is_empty() or formation.saved_front_to_rear_ids.is_empty(): party_notice = _tr("请先保存当前队列顺序。", "Save the current line order first.") show_party_line(resolved) return guild_company = resolved party_draft_order.assign(formation.saved_front_to_rear_ids) party_preview = { "relations": Array(formation.relations).duplicate(true), "coverage": Dictionary(formation.coverage).duplicate(true), } var readiness_snapshot: Dictionary = readiness_page_controller.snapshot( resolved, formation, _readiness_warning_facts(formation), "", String(settings.get("locale", "zh_CN")) ) if not bool(readiness_snapshot.get("ok", false)): party_notice = _formation_error_text(String(readiness_snapshot.get("code", "READINESS_LOAD_FAILED"))) show_party_line(resolved) return readiness_view_data = readiness_snapshot current_screen = "readiness_review" for child in get_children(): child.free() var view := ReadinessPageViewScript.new() view.name = "ReadinessView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "readiness_review", "caller_id": "party_line", "return_page_id": "party_line", "return_focus_path": "ReadinessBack", "metadata": { "company_id": String(resolved.get("company_id", "")), "order_hash": String(readiness_snapshot.get("order", {}).get("order_hash", "")), }, })) view.intent_emitted.connect(_on_readiness_view_intent, CONNECT_DEFERRED) add_child(view) readiness_view = view view.render(readiness_snapshot) func _on_readiness_view_intent(intent_id: String, payload: Dictionary) -> void: match intent_id: "settings": _readiness_open_settings() "review_member": _readiness_review_member(String(payload.get("recruit_id", ""))) "review_warnings": _readiness_review_warnings() "revise": revise_readiness() "back": cancel_party_line() "accept": accept_readiness() "risk": open_risk_board() func _readiness_warning_facts(formation: Dictionary) -> Array: var lines: Array[String] = [] var coverage: Dictionary = Dictionary(formation.get("coverage", {})) if int(coverage.get("protection", 0)) == 0: lines.append(_tr("无保护职责", "NO GUARD")) elif int(coverage.get("long_reach", 0)) == 0: lines.append(_tr("无整线射程", "NO REACH")) elif int(coverage.get("sustain", 0)) == 0: lines.append(_tr("无续航职责", "NO SUSTAIN")) else: lines.append(_tr("覆盖完整", "COVERAGE OK")) lines.append(_tr("结果不保证", "Outcome not guaranteed")) lines.append(_tr("预备席:无", "Reserve: none")) var facts: Array = [] for index in range(lines.size()): facts.append({ "index": index, "text": lines[index], "severity": ["warning", "notice", "info"][index], "owner": ["coverage", "risk", "formation"][index], }) return facts func _readiness_review_member(recruit_id: String) -> void: party_notice = _tr("已查看 %s 的冻结身份;队列顺序不会在准备检查中改变。", "Reviewed %s's frozen identity; the order does not change in Readiness Review.") % _recruit_name(_recruit_by_id(guild_company, recruit_id)) show_readiness_review(guild_company) func _readiness_review_warnings() -> void: party_notice = _tr("警告只来自已保存的覆盖事实;允许的警告不会阻止接受准备。", "Warnings come from saved coverage facts; allowed warnings do not block preparation.") show_readiness_review(guild_company) func _readiness_open_settings() -> void: settings_caller = "readiness" show_settings() func revise_readiness() -> void: party_selected_recruit_id = "" party_undo_order.clear() party_notice = _tr("已加载保存的顺序;修改后需要再次保存并接受。", "Saved order loaded; save and accept again after any change.") show_party_line(guild_company) func accept_readiness() -> void: formation_risk_service.company_path = initial_company_service.company_path var formation: Dictionary = guild_company.active_contract.formation var order_hash := JSON.stringify(_canonical_value(formation.saved_front_to_rear_ids)).sha256_text() var operation_id := ("%s|accept-readiness|%s" % [String(guild_company.company_id), order_hash]).sha256_text().substr(0, 32) var accepted: Dictionary = formation_risk_service.accept_readiness(operation_id, order_hash) if not accepted.ok: party_notice = _formation_error_text(String(accepted.get("code", "READINESS_FAILED"))) show_readiness_review(guild_company) return guild_company = accepted.company.duplicate(true) show_readiness_review(guild_company) func open_risk_board() -> void: show_risk_board(guild_company) func show_risk_board(company := {}) -> void: formation_risk_service.company_path = initial_company_service.company_path var loaded: Dictionary = formation_risk_service.load_company() if not loaded.ok: party_notice = _formation_error_text(String(loaded.get("code", "RISK_LOAD_FAILED"))) show_readiness_review(company) return var resolved: Dictionary = loaded.company.duplicate(true) var formation: Dictionary = resolved.active_contract.get("formation", {}) if formation.is_empty() or String(formation.get("status", "draft")) != "ready": party_notice = _tr("请先接受准备检查。", "Accept the readiness review first.") show_readiness_review(resolved) return var risk_snapshot: Dictionary = risk_board_page_controller.snapshot( resolved, formation, risk_selected_id, String(settings.get("locale", "zh_CN")) ) if not bool(risk_snapshot.get("ok", false)): party_notice = _formation_error_text(String(risk_snapshot.get("code", "RISK_LOAD_FAILED"))) show_readiness_review(resolved) return guild_company = resolved risk_view_data = risk_snapshot current_screen = "risk_board" for child in get_children(): child.free() var view := RiskBoardPageViewScript.new() view.name = "RiskBoardView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "risk_board", "caller_id": "readiness_review", "return_page_id": "readiness_review", "return_focus_path": "RiskBackReadiness", "metadata": { "selected_risk_id": risk_selected_id, "snapshot_hash": String(risk_snapshot.get("party", {}).get("snapshot_hash", "")), }, })) view.intent_emitted.connect(_on_risk_board_view_intent, CONNECT_DEFERRED) add_child(view) risk_board_view = view view.render(risk_snapshot) func _on_risk_board_view_intent(intent_id: String, payload: Dictionary) -> void: match intent_id: "select_risk": select_risk_option(String(payload.get("risk_option_id", ""))) "review": open_risk_commitment() "back": back_to_guild() "settings": _risk_open_settings() func _risk_open_settings() -> void: settings_caller = "risk_board" show_settings() func select_risk_option(option_id: String) -> void: risk_selected_id = option_id show_risk_board(guild_company) func open_risk_commitment() -> void: if risk_selected_id.is_empty(): show_risk_board(guild_company) return show_risk_commitment(guild_company) func show_risk_commitment(company := {}) -> void: formation_risk_service.company_path = initial_company_service.company_path var loaded: Dictionary = formation_risk_service.load_company() if not loaded.ok: show_readiness_review(company) return var resolved: Dictionary = loaded.company.duplicate(true) var formation: Dictionary = resolved.active_contract.get("formation", {}) var option := _risk_option_by_id(formation, risk_selected_id) if option.is_empty(): party_notice = _tr("该风险选项已失效,请重新检查风险板。", "That risk option is stale; review the Risk Board again.") show_risk_board(resolved) return guild_company = resolved current_screen = "risk_commitment" var snapshot: Dictionary = formation.get("accepted_party_snapshot", {}) var snapshot_hash := String(snapshot.get("snapshot_hash", "")) var option_hash := String(option.get("option_hash", "")) var contract_hash := String(resolved.active_contract.get("contract_hash", "")) var had_binding := not risk_commitment_bound_risk_id.is_empty() var binding_changed := risk_commitment_bound_risk_id != risk_selected_id or risk_commitment_bound_snapshot_hash != snapshot_hash or risk_commitment_bound_option_hash != option_hash or risk_commitment_bound_contract_hash != contract_hash if risk_commitment_bound_risk_id.is_empty(): risk_commitment_bound_risk_id = risk_selected_id risk_commitment_bound_snapshot_hash = snapshot_hash risk_commitment_bound_option_hash = option_hash risk_commitment_bound_contract_hash = contract_hash elif binding_changed: risk_commitment_acknowledged = false risk_commitment_notice = _tr("队伍或风险投影已变化,请重新确认。", "The Party or risk projection changed; confirm again.") if binding_changed: risk_commitment_bound_risk_id = risk_selected_id risk_commitment_bound_snapshot_hash = snapshot_hash risk_commitment_bound_option_hash = option_hash risk_commitment_bound_contract_hash = contract_hash var dependency := _risk_commitment_dependency_status(resolved, formation, option) var binding := { "selected_risk_id": risk_selected_id, "snapshot_hash": snapshot_hash, "option_hash": option_hash, "contract_hash": contract_hash, "stale": had_binding and binding_changed, } var commitment_snapshot: Dictionary = risk_commitment_page_controller.snapshot( resolved, formation, option, _risk_commitment_consequence_facts(resolved.active_contract, option), dependency, binding, risk_commitment_acknowledged, String(settings.get("locale", "zh_CN")) ) if not bool(commitment_snapshot.get("ok", false)): party_notice = _formation_error_text(String(commitment_snapshot.get("code", "RISK_COMMITMENT_LOAD_FAILED"))) show_risk_board(resolved) return commitment_snapshot["notice"] = risk_commitment_notice risk_commitment_view_data = commitment_snapshot for child in get_children(): child.free() var view := RiskCommitmentPageViewScript.new() view.name = "RiskCommitmentView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "risk_commitment", "caller_id": "risk_board", "return_page_id": "risk_board", "return_focus_path": "RiskCommitmentCancel", "metadata": { "risk_option_id": risk_selected_id, "snapshot_hash": snapshot_hash, "option_hash": option_hash, }, })) view.intent_emitted.connect(_on_risk_commitment_view_intent, CONNECT_DEFERRED) add_child(view) risk_commitment_view = view view.render(commitment_snapshot) func _on_risk_commitment_view_intent(intent_id: String, _payload: Dictionary) -> void: match intent_id: "back": cancel_risk_commitment() "review_risk_details": review_risk_commitment_details() "review_consequences": review_risk_commitment_consequences() "acknowledge": toggle_risk_commitment_confirmation() "begin_battle": begin_battle() "settings": _risk_commitment_open_settings() func _risk_commitment_consequence_facts(contract: Dictionary, option: Dictionary) -> Array: return [ { "id": "threat", "label": _tr("威胁", "THREAT"), "text": _tr("%d人 · %d波", "%d · %d WAVES") % [int(option.get("known_threat", {}).get("enemy_count", 0)), int(option.get("known_threat", {}).get("wave_count", 0))], "value": _tr("%d人 · %d波", "%d · %d WAVES") % [int(option.get("known_threat", {}).get("enemy_count", 0)), int(option.get("known_threat", {}).get("wave_count", 0))], "glyph": "!", "color": Color("#a74337"), }, { "id": "uncertainty", "label": _tr("不确定", "UNCERTAINTY"), "text": _risk_uncertainty_short(String(option.get("uncertainty_id", ""))), "value": _risk_uncertainty_short(String(option.get("uncertainty_id", ""))), "glyph": "?", "color": Color("#5d3f91"), }, { "id": "entry", "label": _tr("入场 Coin", "ENTRY COIN"), "text": _tr("%d · 余额 %d", "%d · %d LEFT") % [int(option.get("entry_cost", 0)), int(contract.get("coin", 0))], "value": _tr("%d · 余额 %d", "%d · %d LEFT") % [int(option.get("entry_cost", 0)), int(contract.get("coin", 0))], "glyph": "¢", "color": Color("#a07816"), }, { "id": "reward", "label": _tr("胜利回报", "REWARD"), "text": _tr("%d · %s", "%d · %s") % [int(option.get("reward_coin", 0)), _risk_reward_short(String(option.get("reward_class_id", "")))], "value": _tr("%d · %s", "%d · %s") % [int(option.get("reward_coin", 0)), _risk_reward_short(String(option.get("reward_class_id", "")))], "glyph": "◆", "color": Color("#3d7449"), }, { "id": "recovery", "label": _tr("恢复暴露", "RECOVERY"), "text": _recovery_name(String(option.get("recovery_exposure_id", ""))), "value": _recovery_name(String(option.get("recovery_exposure_id", ""))), "glyph": "!", "color": Color("#a44a2e"), }, { "id": "retreat", "label": _tr("撤退规则", "RETREAT"), "text": _tr("下阶段归队", "NEXT STAGE"), "value": _tr("下阶段归队", "NEXT STAGE"), "glyph": "↩", "color": Color("#2c67a3"), }, ] func _risk_commitment_dependency_status(company: Dictionary, formation: Dictionary, option: Dictionary) -> Dictionary: if formation.is_empty() or String(formation.get("status", "")) != "ready": return {"ok": false, "code": "READINESS_REQUIRED"} var snapshot: Dictionary = formation.get("accepted_party_snapshot", {}) var ids: Array = Array(snapshot.get("front_to_rear_ids", [])) if ids.size() != 4 or String(snapshot.get("snapshot_hash", "")).is_empty(): return {"ok": false, "code": "PARTY_SNAPSHOT_INVALID"} var seen := {} for recruit_id in ids: var id := String(recruit_id) if id.is_empty() or seen.has(id) or _recruit_by_id(company, id).is_empty(): return {"ok": false, "code": "PARTY_SNAPSHOT_INVALID"} seen[id] = true var current_option := _risk_option_by_id(formation, String(option.get("risk_option_id", ""))) if current_option.is_empty() or String(current_option.get("option_hash", "")) != String(option.get("option_hash", "")): return {"ok": false, "code": "RISK_PROJECTION_STALE"} var contract: Dictionary = company.get("active_contract", {}) var cost := int(option.get("entry_cost", 0)) var coin := int(contract.get("coin", 0)) if coin < cost: return {"ok": false, "code": "INSUFFICIENT_COIN", "coin": coin, "cost": cost} return {"ok": true, "code": "READY", "snapshot_hash": String(snapshot.snapshot_hash), "option_hash": String(option.option_hash), "contract_hash": String(contract.get("contract_hash", ""))} func _risk_commitment_block_reason(dependency: Dictionary, acknowledged: bool) -> String: if not acknowledged: return _tr("请先确认已读六条后果。", "Acknowledge the six consequences first.") match String(dependency.get("code", "")): "READINESS_REQUIRED": return _tr("准备快照不可用,请返回准备检查。", "The readiness snapshot is unavailable; return to Readiness Review.") "PARTY_SNAPSHOT_INVALID": return _tr("队伍快照无效,请返回准备检查。", "The Party snapshot is invalid; return to Readiness Review.") "RISK_PROJECTION_STALE": return _tr("风险投影已失效,请返回风险板。", "The risk projection is stale; return to the Risk Board.") "INSUFFICIENT_COIN": return _tr("金币不足:需要 %d,当前 %d。", "Insufficient Coin: need %d, have %d.") % [int(dependency.get("cost", 0)), int(dependency.get("coin", 0))] return _tr("依赖检查未完成。", "Dependency checks are not complete.") func _risk_commitment_open_settings() -> void: settings_caller = "risk_commitment" show_settings() func review_risk_commitment_details() -> void: risk_commitment_notice = _tr("风险身份、来源和六个值均来自当前投影。", "Identity, source and six values come from the current projection.") show_risk_commitment(guild_company) func review_risk_commitment_consequences() -> void: risk_commitment_notice = _tr("后果顺序:威胁、不确定、入场、回报、恢复、撤退。", "Order: threat, uncertainty, entry, reward, recovery, retreat.") show_risk_commitment(guild_company) func toggle_risk_commitment_confirmation() -> void: risk_commitment_acknowledged = not risk_commitment_acknowledged risk_commitment_notice = _tr("已确认六条后果;现在可以进入战斗开场。", "Six consequences acknowledged; Battle Opening is now available.") if risk_commitment_acknowledged else _tr("确认已撤回;不会创建承诺回执。", "Acknowledgement withdrawn; no commitment receipt will be created.") show_risk_commitment(guild_company) func back_to_readiness() -> void: show_readiness_review(guild_company) func cancel_risk_commitment() -> void: risk_commitment_acknowledged = false risk_commitment_notice = "" risk_commitment_receipt = {} show_risk_board(guild_company) func begin_battle() -> void: if risk_selected_id.is_empty(): show_risk_board(guild_company) return if not risk_commitment_acknowledged: risk_commitment_notice = _tr("请先确认已读后果。", "Acknowledge the consequences before beginning battle.") show_risk_commitment(guild_company) return formation_risk_service.company_path = initial_company_service.company_path var formation_loaded: Dictionary = formation_risk_service.load_company() if not formation_loaded.ok: party_notice = _formation_error_text(String(formation_loaded.get("code", "RISK_LOAD_FAILED"))) show_readiness_review(guild_company) return var validated_company: Dictionary = formation_loaded.company var validated_formation: Dictionary = validated_company.active_contract.get("formation", {}) var validated_option := _risk_option_by_id(validated_formation, risk_selected_id) var dependency := _risk_commitment_dependency_status(validated_company, validated_formation, validated_option) if not bool(dependency.get("ok", false)): risk_commitment_notice = _risk_commitment_block_reason(dependency, true) show_risk_commitment(validated_company) return battle_service.company_path = initial_company_service.company_path var loaded: Dictionary = battle_service.load_company() if not loaded.ok: party_notice = _battle_error_text(String(loaded.get("code", "BATTLE_LOAD_FAILED"))) show_readiness_review(guild_company) return var snapshot_hash := String(Dictionary(loaded.company.active_contract.get("formation", {}).get("accepted_party_snapshot", {})).get("snapshot_hash", "")) var operation_id := ("%s|battle|%s|%s" % [String(loaded.company.company_id), risk_selected_id, snapshot_hash]).sha256_text().substr(0, 32) var started: Dictionary = battle_service.begin_battle(operation_id, risk_selected_id) if not started.ok: party_notice = _battle_error_text(String(started.get("code", "BATTLE_START_FAILED"))) show_risk_commitment(loaded.company) return risk_commitment_receipt = Dictionary(started.get("receipt", {})).duplicate(true) battle_operation_id = operation_id battle_paused = false battle_speed = 1.0 battle_notice = "" guild_company = started.company.duplicate(true) show_battle_opening(guild_company) func show_battle_opening(company := {}, auto_focus := true) -> void: _clear_battle_playback_timer() battle_playback_generation += 1 battle_opening_mode = true var preserve_handoff := battle_opening_state == "opening_handoff" and not battle_opening_handoff_receipt.is_empty() battle_opening_notice = "" if not preserve_handoff: battle_opening_state = "ready_to_observe" battle_opening_handoff_receipt = {} battle_service.company_path = initial_company_service.company_path var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else {} if resolved.is_empty(): var loaded := battle_service.load_company() if not loaded.ok: _show_battle_opening_invalid(_battle_error_text(String(loaded.get("code", "BATTLE_LOAD_FAILED")))) return resolved = loaded.company.duplicate(true) var contract: Dictionary = resolved.get("active_contract", {}) var battle: Dictionary = contract.get("battle_state", {}) if battle.is_empty() or String(battle.get("status", "")) not in ["active"]: _show_battle_opening_invalid(_tr("没有可恢复的战斗开场回执。", "No resumable Battle Opening receipt exists.")) return if int(battle.get("phase_index", 0)) > 0: battle_opening_state = "interrupted_resume" battle_opening_notice = _tr("已恢复原战斗回执;请重新确认后再交给观察页。", "The original battle receipt was restored; confirm the opening again before entering observation.") battle_operation_id = String(battle.get("operation_id", "")) guild_company = resolved # A resumed/loaded opening may not have the transient RSK-002 UI receipt in # memory. Resolve the persisted commitment identity from the same contract # so the opening never renders a blank or fabricated receipt slot. if risk_commitment_receipt.is_empty(): # The current Company schema persists the battle receipt, while the # commitment review object is intentionally transient. Reuse the linked # operation identity rather than inventing a second commitment record. var persisted_battles: Array = Array(contract.get("battle_receipts", [])) for persisted in persisted_battles: if String(persisted.get("operation_id", "")) == battle_operation_id: risk_commitment_receipt = Dictionary(persisted).duplicate(true) break var opening_snapshot: Dictionary = battle_opening_page_controller.snapshot( resolved, battle, battle_opening_state, battle_opening_notice, battle_opening_handoff_receipt, risk_commitment_receipt, String(settings.get("locale", "zh_CN")) ) if not bool(opening_snapshot.get("ok", false)): _show_battle_opening_invalid(_battle_error_text(String(opening_snapshot.get("code", "BATTLE_LOAD_FAILED")))) return battle_opening_view_data = opening_snapshot current_screen = "battle" _build_battle_opening_canvas() _build_battle_opening_overlay(resolved, battle) if auto_focus: var start := find_child("BattleStartObservation", true, false) as Button if start != null and not start.disabled: start.call_deferred("grab_focus") func _show_battle_opening_invalid(reason: String) -> void: battle_opening_mode = true battle_opening_state = "no_resumable_receipt" battle_opening_notice = reason current_screen = "battle" _build_battle_opening_canvas() var canvas := battle_opening_canvas var box := Control.new() box.name = "BattleOpeningInvalid" box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) canvas.add_child(box) var title := _battle_opening_overlay_label(box, "BattleOpeningInvalidTitle", _tr("战斗开场不可用", "BATTLE OPENING UNAVAILABLE"), Rect2(0.20, 0.36, 0.60, 0.07), 24, Color("#f5ead0")) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var detail := _battle_opening_overlay_label(box, "BattleOpeningInvalidReason", reason, Rect2(0.18, 0.45, 0.64, 0.10), 14, Color("#d5c8aa")) detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART var back := _battle_opening_button(box, "BattleOpeningInvalidBack", _tr("返回风险承诺", "Return to Risk Commitment"), Rect2(0.38, 0.66, 0.24, 0.14), cancel_risk_commitment, false) back.call_deferred("grab_focus") func _build_battle_opening_canvas() -> void: for child in get_children(): child.free() var background := TextureRect.new() background.name = "BattleOpeningWorld" background.texture = BATTLE_OPENING_BACKGROUND background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(background) var shade := ColorRect.new() shade.name = "BattleOpeningWorldShade" shade.color = Color(0.02, 0.05, 0.08, 0.08) shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) shade.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(shade) var canvas := Control.new() canvas.name = "BattleOpeningCanvas" canvas.mouse_filter = Control.MOUSE_FILTER_PASS add_child(canvas) battle_opening_canvas = canvas _layout_battle_opening_canvas() func _layout_battle_opening_canvas() -> void: if not is_instance_valid(battle_opening_canvas): return var available := size if available.x <= 0.0 or available.y <= 0.0: return const DESIGN_ASPECT := 16.0 / 9.0 var canvas_size: Vector2 if available.x / available.y >= DESIGN_ASPECT: canvas_size = Vector2(available.y * DESIGN_ASPECT, available.y) else: canvas_size = Vector2(available.x, available.x / DESIGN_ASPECT) battle_opening_canvas.position = (available - canvas_size) * 0.5 battle_opening_canvas.size = canvas_size func _build_battle_opening_overlay(company: Dictionary, battle: Dictionary) -> void: if not is_instance_valid(battle_opening_canvas): return var art := BattleOpeningArt.new() art.name = "BattleOpeningArt" art.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) art.set_state(true, battle_opening_state == "opening_handoff") battle_opening_canvas.add_child(art) var overlay := Control.new() overlay.name = "BattleOpeningLayout" overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) overlay.mouse_filter = Control.MOUSE_FILTER_PASS battle_opening_canvas.add_child(overlay) _build_battle_opening_top(overlay, company, battle, battle_opening_view_data) _build_battle_opening_field(overlay, company, battle, battle_opening_view_data) _build_battle_opening_bottom(overlay, company, battle, battle_opening_view_data) func _build_battle_opening_top(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var view_battle: Dictionary = Dictionary(view_data.get("battle", {})) var view_enemies: Dictionary = Dictionary(view_data.get("enemies", {})) var party_count := Array(view_battle.get("front_to_rear_ids", battle.get("front_to_rear_ids", []))).size() var enemy_count := int(view_enemies.get("count", Array(battle.get("enemy_ids", [])).size())) var company_status := _battle_opening_overlay_label(parent, "BattleCompanyStatus", _tr("远征团 %d", "COMPANY %d") % party_count, Rect2(0.105, 0.030, 0.20, 0.055), 16, Color("#f5ead0")) company_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var enemy_status := _battle_opening_overlay_label(parent, "BattleEnemyStatus", _tr("敌方 %d", "ENEMY %d") % enemy_count, Rect2(0.735, 0.030, 0.18, 0.055), 16, Color("#f5ead0")) enemy_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT var pause := _battle_opening_button(parent, "BattlePause", "II", Rect2(0.405, 0.005, 0.065, 0.14), review_battle_opening_state, false) pause.tooltip_text = _tr("战斗时间线将在下一页开始", "The battle timeline begins on the next page") var speed := _battle_opening_button(parent, "BattleSpeed", "x1", Rect2(0.468, 0.005, 0.065, 0.14), review_battle_speed, false) speed.tooltip_text = _tr("观察速度由 BAT-004 管理", "Observation speed is owned by BAT-004") var inspect := _battle_opening_button(parent, "BattleInspect", "VIEW", Rect2(0.531, 0.005, 0.065, 0.14), inspect_battle_opening, false) inspect.tooltip_text = _tr("检查开场事实", "Inspect opening facts") var settings_button := _battle_opening_button(parent, "BattleOpeningSettings", _tr("设置", "SETTINGS"), Rect2(0.905, 0.005, 0.09, 0.14), _battle_open_settings, false) settings_button.add_theme_font_size_override("font_size", _scaled(10)) func _build_battle_opening_field(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var view_battle: Dictionary = Dictionary(view_data.get("battle", {})) var view_party: Dictionary = Dictionary(view_data.get("party", {})) var view_enemies: Dictionary = Dictionary(view_data.get("enemies", {})) var objective_title := _battle_opening_overlay_label(parent, "BattleOpeningTitle", _tr("风镜初渡 · 战斗开场", "FIRST WINDGLASS CROSSING · BATTLE OPENING"), Rect2(0.045, 0.165, 0.28, 0.035), 13, Color("#e8d39d")) objective_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var objective := _battle_opening_overlay_label(parent, "BattleOpeningObjective", _tr("目标:击破敌方队列", "OBJECTIVE: BREAK THE ENEMY LINE"), Rect2(0.045, 0.205, 0.28, 0.055), 15, Color("#f5ead0")) objective.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var receipt_data: Dictionary = Dictionary(Dictionary(view_data.get("receipt", {})).get("battle", risk_commitment_receipt)) var commitment := _battle_opening_overlay_label(parent, "BattleOpeningCommitment", _tr("承诺回执 %s", "COMMITMENT %s") % _short_hash(receipt_data.get("receipt_hash", "")), Rect2(0.045, 0.255, 0.28, 0.030), 9, Color("#b8d6e8")) commitment.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var no_respawn := _battle_opening_overlay_label(parent, "BattleOpeningNoRespawn", _tr("无战斗内复活", "NO COMBAT RESPAWN"), Rect2(0.045, 0.305, 0.28, 0.035), 11, Color("#f0c979")) no_respawn.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var no_reserve := _battle_opening_overlay_label(parent, "BattleOpeningNoReserve", _tr("无 Reserve 自动入场", "NO RESERVE AUTO-ENTRY"), Rect2(0.045, 0.350, 0.28, 0.035), 11, Color("#f0c979")) no_reserve.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var actors := Control.new() actors.name = "BattleActors" actors.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) actors.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(actors) var ids: Array = Array(view_party.get("front_to_rear_ids", view_battle.get("front_to_rear_ids", battle.get("front_to_rear_ids", [])))) var visual_slots := [Rect2(0.075, 0.455, 0.115, 0.235), Rect2(0.185, 0.445, 0.115, 0.245), Rect2(0.295, 0.435, 0.115, 0.255), Rect2(0.405, 0.425, 0.115, 0.265)] for visual_index in range(ids.size()): var source_index := ids.size() - 1 - visual_index var recruit_id := String(ids[source_index]) var recruit := _recruit_by_id(company, recruit_id) var rect: Rect2 = visual_slots[mini(visual_index, visual_slots.size() - 1)] var actor := _battle_opening_actor(source_index, recruit, battle, rect) actors.add_child(actor) var enemies := Control.new() enemies.name = "BattleEnemies" enemies.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) enemies.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(enemies) var enemy_ids: Array = Array(view_enemies.get("ids", view_battle.get("enemy_ids", battle.get("enemy_ids", [])))) var enemy_slots := [Rect2(0.625, 0.475, 0.105, 0.22), Rect2(0.735, 0.455, 0.105, 0.24), Rect2(0.845, 0.435, 0.105, 0.26)] for enemy_index in range(enemy_ids.size()): var enemy_rect: Rect2 = enemy_slots[mini(enemy_index, enemy_slots.size() - 1)] enemies.add_child(_battle_opening_enemy(enemy_index, String(enemy_ids[enemy_index]), battle, enemy_rect)) var orientation := _battle_opening_overlay_label(parent, "BattleOpeningOrientation", _tr("后排在左 · 0 号前排在右", "REAR LEFT · FRONT POSITION 0 RIGHT"), Rect2(0.075, 0.705, 0.30, 0.035), 9, Color("#c7ddb0")) orientation.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var enemy_direction := _battle_opening_overlay_label(parent, "BattleOpeningEnemyDirection", _tr("敌方朝向 ◀ 接触走廊", "ENEMY FACING ◀ CONTACT CORRIDOR"), Rect2(0.62, 0.705, 0.30, 0.035), 9, Color("#e9b8a3")) enemy_direction.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT var threat_label := _battle_opening_overlay_label(parent, "BattleOpeningFirstThreat", _tr("首要威胁", "FIRST THREAT"), Rect2(0.535, 0.235, 0.12, 0.030), 9, Color("#ffe7bf")) threat_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var threat_value := _battle_opening_overlay_label(parent, "BattleOpeningThreatValue", _first_battle_threat_copy(view_battle.get("copy", battle)), Rect2(0.505, 0.270, 0.18, 0.035), 8, Color("#fff1db")) threat_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER threat_value.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART var corridor := _battle_opening_overlay_label(parent, "BattleOpeningCorridor", _tr("移动走廊", "MOVEMENT CORRIDOR"), Rect2(0.355, 0.565, 0.21, 0.035), 9, Color("#f7d88b")) corridor.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var state_reason := _battle_opening_overlay_label(parent, "BattleOpeningReason", String(Dictionary(view_data.get("state", {})).get("notice", battle_opening_notice)), Rect2(0.34, 0.735, 0.32, 0.045), 10, Color("#e8d39d")) state_reason.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER state_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART state_reason.visible = not battle_opening_notice.is_empty() var context := _battle_opening_overlay_label(parent, "BattleEventContext", _tr("开场事实已锁定 · 尚未开始时间线", "OPENING FACTS LOCKED · TIMELINE NOT STARTED"), Rect2(0.34, 0.695, 0.32, 0.035), 8, Color("#d2e0dd")) context.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER func _build_battle_opening_bottom(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var view_battle: Dictionary = Dictionary(view_data.get("battle", {})) var view_party: Dictionary = Dictionary(view_data.get("party", {})) var view_commands: Dictionary = Dictionary(view_data.get("commands", {})) var view_state: Dictionary = Dictionary(view_data.get("state", {})) var snapshot_title := _battle_opening_overlay_label(parent, "BattleOpeningSnapshot", _tr("冻结队列", "FROZEN COMPANY"), Rect2(0.175, 0.845, 0.14, 0.030), 10, Color("#e8d39d")) snapshot_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var snapshot_hash := _battle_opening_overlay_label(parent, "BattleOpeningSnapshotHash", _short_hash(view_battle.get("snapshot_hash", battle.get("snapshot_hash", ""))), Rect2(0.46, 0.845, 0.30, 0.030), 9, Color("#9ec9dd")) snapshot_hash.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT var ids: Array = Array(view_party.get("front_to_rear_ids", view_battle.get("front_to_rear_ids", battle.get("front_to_rear_ids", [])))) for visual_index in range(ids.size()): var source_index := ids.size() - 1 - visual_index var recruit := _recruit_by_id(company, String(ids[source_index])) var cell := _battle_opening_snapshot_cell(source_index, recruit, Rect2(0.17 + visual_index * 0.155, 0.875, 0.145, 0.095)) parent.add_child(cell) var inspect := _battle_opening_button(parent, "BattleInspectOpening", _tr("检查开场", "INSPECT"), Rect2(0.025, 0.835, 0.145, 0.145), inspect_battle_opening, false) inspect.tooltip_text = _tr("检查承诺、顺序和首要威胁", "Inspect commitment, order and first threat") var start_disabled := not bool(view_commands.get("start_observation_enabled", battle_opening_state != "opening_handoff")) var start_text := _tr("已交接", "HANDED OFF") if start_disabled else _tr("开始观察", "START OBSERVATION") var start := _battle_opening_button(parent, "BattleStartObservation", start_text, Rect2(0.805, 0.835, 0.17, 0.145), start_observation_handoff, true) start.disabled = start_disabled start.tooltip_text = _tr("交给 BAT-002;不会在此页推进时间", "Hand off to BAT-002; this page never advances time") if start_disabled: _set_battle_opening_button_disabled(start, true) var committed_rule := _battle_opening_overlay_label(parent, "BattleCommittedRule", _tr("风险已提交 · 无战斗指令", "RISK COMMITTED · NO COMBAT COMMANDS"), Rect2(0.655, 0.945, 0.30, 0.035), 8, Color("#d8c79f")) committed_rule.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT var handoff := _battle_opening_overlay_label(parent, "BattleOpeningHandoff", _tr("开场回执已交给 BAT-002", "OPENING RECEIPT HANDED TO BAT-002"), Rect2(0.36, 0.775, 0.28, 0.055), 10, Color("#b9e4bf")) handoff.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER handoff.visible = bool(view_state.get("handoff", battle_opening_state == "opening_handoff")) var receipt_data: Dictionary = Dictionary(Dictionary(view_data.get("receipt", {})).get("battle", {})) var receipt_hash := String(receipt_data.get("receipt_hash", _battle_receipt_hash(company, battle))) var receipt := _battle_opening_overlay_label(parent, "BattleOpeningReceipt", _tr("战斗回执 %s", "BATTLE RECEIPT %s") % _short_hash(receipt_hash), Rect2(0.36, 0.815, 0.28, 0.030), 9, Color("#d6e8ed")) receipt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER receipt.visible = battle_opening_state == "opening_handoff" var next := _battle_opening_overlay_label(parent, "BattleOpeningNextBoundary", _tr("下一页负责确定时序;本页不模拟", "The next page owns the timeline; this page does not simulate"), Rect2(0.34, 0.945, 0.32, 0.035), 8, Color("#b6c8ce")) next.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER next.visible = bool(view_state.get("handoff", battle_opening_state == "opening_handoff")) func _battle_opening_actor(position: int, recruit: Dictionary, battle: Dictionary, rect: Rect2) -> Control: var actor := Control.new() actor.name = "BattleActor%d" % position _set_normalized_rect(actor, rect) actor.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.set_meta("position", position) actor.set_meta("recruit_id", String(recruit.get("recruit_id", ""))) var shadow := ColorRect.new() shadow.name = "BattleActorGround" shadow.anchor_left = 0.16 shadow.anchor_top = 0.83 shadow.anchor_right = 0.84 shadow.anchor_bottom = 0.88 shadow.color = Color("#10211f", 0.35) shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(shadow) var portrait := TextureRect.new() portrait.name = "BattlePortrait" portrait.texture = _battle_opening_actor_texture(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.04 portrait.anchor_top = 0.0 portrait.anchor_right = 0.96 portrait.anchor_bottom = 0.84 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(portrait) var bar := ColorRect.new() bar.name = "BattleActorHP" bar.anchor_left = 0.13 bar.anchor_top = 0.79 bar.anchor_right = 0.87 bar.anchor_bottom = 0.825 bar.color = Color("#69b76b") bar.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(bar) var pos_label := _label(_tr("%d 号", "POS %d") % position, _scaled(8), Color("#f5e6b8")) pos_label.name = "BattlePositionLabel" pos_label.anchor_left = 0.05 pos_label.anchor_top = 0.875 pos_label.anchor_right = 0.95 pos_label.anchor_bottom = 0.94 pos_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER pos_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER pos_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS pos_label.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(pos_label) var name_label := _label(_recruit_name(recruit), _scaled(8), Color("#ffffff")) name_label.name = "BattleActorName" name_label.anchor_left = 0.02 name_label.anchor_top = 0.935 name_label.anchor_right = 0.98 name_label.anchor_bottom = 1.0 name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(name_label) return actor func _battle_opening_enemy(index: int, enemy_id: String, battle: Dictionary, rect: Rect2) -> Control: var enemy := Control.new() enemy.name = "BattleEnemy%d" % index _set_normalized_rect(enemy, rect) enemy.mouse_filter = Control.MOUSE_FILTER_IGNORE var portrait := TextureRect.new() portrait.name = "BattleEnemyPortrait" portrait.texture = BATTLE_OPENING_ENEMY portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.08 portrait.anchor_top = 0.0 portrait.anchor_right = 0.92 portrait.anchor_bottom = 0.82 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(portrait) var bar := ColorRect.new() bar.name = "BattleEnemyHP" bar.anchor_left = 0.12 bar.anchor_top = 0.78 bar.anchor_right = 0.88 bar.anchor_bottom = 0.82 bar.color = Color("#d95f59") if index == 0 else Color("#a94b54") bar.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(bar) var label := _label(_tr("威胁 %d", "THREAT %d") % (index + 1), _scaled(8), Color("#ffe5d1")) label.name = "BattleEnemyLabel" label.anchor_top = 0.86 label.anchor_right = 1.0 label.anchor_bottom = 1.0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(label) enemy.set_meta("enemy_id", enemy_id) return enemy func _battle_opening_snapshot_cell(position: int, recruit: Dictionary, rect: Rect2) -> Control: var cell := Control.new() cell.name = "BattleSnapshotCell%d" % position _set_normalized_rect(cell, rect) cell.mouse_filter = Control.MOUSE_FILTER_IGNORE var back := ColorRect.new() back.name = "BattleSnapshotCellBack" back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) back.color = Color("#24384b", 0.78) back.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(back) var portrait := TextureRect.new() portrait.texture = _battle_opening_actor_texture(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.02 portrait.anchor_top = 0.02 portrait.anchor_right = 0.28 portrait.anchor_bottom = 0.98 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(portrait) var label := _label(_tr("%d 号 %s", "POS %d %s") % [position, _recruit_name(recruit)], _scaled(8), Color("#f5ead0")) label.name = "BattleSnapshotIdentity" label.anchor_left = 0.30 label.anchor_top = 0.18 label.anchor_right = 0.97 label.anchor_bottom = 0.62 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(label) var line := ColorRect.new() line.anchor_left = 0.30 line.anchor_top = 0.72 line.anchor_right = 0.94 line.anchor_bottom = 0.79 line.color = Color("#6ebc7a") line.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(line) return cell func _battle_opening_overlay_label(parent: Control, node_name: String, text_value: String, rect: Rect2, font_size: int, color: Color) -> Label: var label := _label(text_value, _scaled(font_size), color) label.name = node_name _set_normalized_rect(label, rect) label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(label) return label func _battle_opening_button(parent: Control, node_name: String, text_value: String, rect: Rect2, callback: Callable, primary: bool) -> Button: var button := Button.new() button.name = node_name button.focus_mode = Control.FOCUS_ALL button.flat = false button.custom_minimum_size = Vector2(48, 48) button.tooltip_text = text_value _set_normalized_rect(button, rect) var normal := _flat_style(Color("#d4b46b", 0.92) if primary else Color("#172536", 0.86), Color("#f1d995", 0.95), 2 if primary else 1, 5) var hover := _flat_style(Color("#ebd28d", 0.96) if primary else Color("#29425b", 0.94), Color("#fff0bf", 1.0), 2, 5) button.add_theme_stylebox_override("normal", normal) button.add_theme_stylebox_override("hover", hover) button.add_theme_stylebox_override("pressed", _flat_style(Color("#a98642", 0.96), Color("#fff0bf", 1.0), 2, 5)) button.add_theme_stylebox_override("focus", hover) button.add_theme_stylebox_override("disabled", _flat_style(Color("#26313b", 0.70), Color("#6a7175", 0.65), 1, 5)) button.add_theme_stylebox_override("hover_pressed", hover) var label := _button_label("ButtonLabel", text_value, _scaled(10 if not primary else 12), Color("#17202a") if primary else Color("#f5ead0")) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS button.add_child(label) button.pressed.connect(callback, CONNECT_DEFERRED) parent.add_child(button) return button func _set_battle_opening_button_disabled(button: Button, disabled: bool) -> void: button.disabled = disabled var label := button.get_node_or_null("ButtonLabel") as Label if label != null: label.add_theme_color_override("font_color", Color("#9aa1a4") if disabled else Color("#17202a")) func _battle_opening_actor_texture(recruit: Dictionary) -> Texture2D: match String(recruit.get("profession_id", "")): "PF-B01", "PF-B02", "PF-B04": return BATTLE_OPENING_HEARTHGUARD "PF-B03", "PF-B09", "PF-B11": return BATTLE_OPENING_WAYRUNNER "PF-B05", "PF-B06", "PF-B10": return BATTLE_OPENING_FARSTRING "PF-B07", "PF-B08", "PF-B12": return BATTLE_OPENING_KINDHAND return InitialCompanyArt.texture_for(recruit) func _first_battle_threat_copy(battle: Dictionary) -> String: var enemy_count := Array(battle.get("enemy_ids", [])).size() return _tr("0 号承压 · %d 敌", "POS 0 EXPOSED · %d ENEMIES") % enemy_count func _battle_receipt_hash(company: Dictionary, battle: Dictionary) -> String: for receipt in company.get("active_contract", {}).get("battle_receipts", []): if String(receipt.get("operation_id", "")) == String(battle.get("operation_id", "")): return String(receipt.get("receipt_hash", "")) return String(battle.get("battle_hash", "")) func _short_hash(value: Variant) -> String: var text := String(value) return text.substr(0, 10) if text.length() > 10 else text func _battle_open_settings() -> void: settings_caller = "battle_opening" show_settings() func review_battle_opening_state() -> void: battle_paused = true battle_opening_notice = _tr("承诺、队列顺序和首要威胁均已冻结;观察尚未开始。", "Commitment, line order and first threat are frozen; observation has not started.") show_battle_opening(guild_company, false) func review_battle_speed() -> void: battle_speed = 2.0 battle_opening_notice = _tr("速度按钮属于 BAT-004;本页只确认开场事实。", "Speed belongs to BAT-004; this page only confirms opening facts.") show_battle_opening(guild_company, false) func inspect_battle_opening() -> void: battle_opening_notice = _tr("同一承诺回执 · 同一 Party 快照 · 无战斗内复活 · 无 Reserve 自动入场。", "Same commitment receipt · same Party snapshot · no combat respawn · no Reserve auto-entry.") show_battle_opening(guild_company, false) func start_observation_handoff() -> void: if not battle_opening_mode or battle_opening_state == "opening_handoff": return battle_service.company_path = initial_company_service.company_path var loaded := battle_service.load_company() if not loaded.ok: battle_opening_state = "snapshot_mismatch" battle_opening_notice = _battle_error_text(String(loaded.get("code", "BATTLE_LOAD_FAILED"))) show_battle_opening(guild_company, false) return var live_company: Dictionary = loaded.company var live_battle: Dictionary = live_company.get("active_contract", {}).get("battle_state", {}) var current_battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {}) if String(live_battle.get("operation_id", "")) != String(current_battle.get("operation_id", "")) or String(live_battle.get("snapshot_hash", "")) != String(current_battle.get("snapshot_hash", "")): battle_opening_state = "snapshot_mismatch" battle_opening_notice = _tr("承诺或 Party 快照已变化;观察被阻止。", "The commitment or Party snapshot changed; observation is blocked.") show_battle_opening(live_company, false) return battle_opening_handoff_receipt = _battle_receipt_for_company(live_company, live_battle) var observation: Dictionary = battle_service.begin_observation( String(live_battle.get("operation_id", "")), String(live_battle.get("snapshot_hash", "")) ) if not observation.ok: battle_opening_state = "snapshot_mismatch" battle_opening_notice = _battle_error_text(String(observation.get("code", "OBSERVATION_START_FAILED"))) show_battle_opening(live_company, false) return battle_opening_state = "opening_handoff" battle_opening_notice = _tr("原战斗回执已交接;BAT-002 负责时序。", "The original battle receipt was handed off; BAT-002 owns the timeline.") guild_company = observation.company.duplicate(true) show_battle(guild_company, true) func _battle_receipt_for_company(company: Dictionary, battle: Dictionary) -> Dictionary: for receipt in company.get("active_contract", {}).get("battle_receipts", []): if String(receipt.get("operation_id", "")) == String(battle.get("operation_id", "")): return Dictionary(receipt).duplicate(true) return {} func show_battle(company := {}, auto_play := false, resume_requires_review := false) -> void: battle_opening_mode = false battle_opening_state = "" battle_opening_handoff_receipt = {} _clear_battle_playback_timer() battle_playback_generation += 1 battle_service.company_path = initial_company_service.company_path var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else {} if resolved.is_empty(): var loaded: Dictionary = battle_service.load_company() if not loaded.ok: party_notice = _battle_error_text(String(loaded.get("code", "BATTLE_LOAD_FAILED"))) show_readiness_review(guild_company) return resolved = loaded.company.duplicate(true) var battle: Dictionary = resolved.get("active_contract", {}).get("battle_state", {}) if battle.is_empty(): party_notice = _tr("没有可恢复的战斗回执。", "No resumable battle receipt exists.") show_readiness_review(resolved) return if String(battle.get("status", "")) == "result": show_battle_result(resolved) return battle_operation_id = String(battle.get("operation_id", "")) if not battle.has("observation") or Dictionary(battle.get("observation", {})).is_empty(): var begun: Dictionary = battle_service.begin_observation(battle_operation_id, String(battle.get("snapshot_hash", ""))) if not begun.ok: party_notice = _battle_error_text(String(begun.get("code", "OBSERVATION_START_FAILED"))) show_battle_opening(resolved, false) return resolved = begun.company.duplicate(true) battle = resolved.active_contract.battle_state guild_company = resolved battle_live_observation = Dictionary(battle.get("observation", {})).duplicate(true) var events: Array = battle.get("phase_events", []) battle_live_event = Dictionary(events.back()).duplicate(true) if not events.is_empty() else {} battle_live_resume_state = "event_resolved" if not battle_live_event.is_empty() else "waiting_for_next_event" battle_live_notice = "" battle_live_error_code = "" battle_live_interruption = {} if String(battle.get("status", "")) == "outcome_locked": battle_live_state = "outcome_locked" battle_paused = true elif resume_requires_review: battle_live_state = "interrupted_background" battle_live_interruption = { "reason": "process_resume", "last_committed_tick": int(battle_live_observation.get("next_phase_index", 0)), "snapshot_id": String(battle.get("snapshot_id", "")), "snapshot_hash": String(battle.get("snapshot_hash", "")), "prior_speed": battle_speed, "prior_paused": battle_paused, } battle_paused = true else: battle_live_state = battle_live_resume_state battle_paused = not auto_play _refresh_battle_live_view_data() current_screen = "battle" _render_battle_live() if auto_play and not battle_paused: _schedule_battle_playback(battle_playback_generation) func _render_battle_live() -> void: if battle_live_rendering: battle_live_render_requested = true return battle_live_rendering = true _refresh_battle_live_view_data() _build_battle_live_canvas() var battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {}) _build_battle_live_overlay(guild_company, battle, battle_live_view_data) _battle_live_wire_focus() var focus_target := find_child("BattleLiveSettings", true, false) as Button if focus_target != null: focus_target.call_deferred("grab_focus") battle_live_rendering = false if battle_live_render_requested: battle_live_render_requested = false call_deferred("_render_battle_live") func _refresh_battle_live_view_data() -> bool: var battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {}) if battle.is_empty(): battle_live_view_data = {} return false var snapshot: Dictionary = battle_live_page_controller.snapshot( guild_company, battle, battle_live_observation, battle_live_event, battle_live_state, battle_live_resume_state, battle_paused, battle_speed, battle_live_notice, battle_live_error_code, battle_live_interruption, battle_pause_log_open, String(settings.get("locale", "zh_CN")) ) if not bool(snapshot.get("ok", false)): battle_live_view_data = {} return false battle_live_view_data = snapshot return true func _build_battle_live_canvas() -> void: for child in get_children(): child.free() var background := TextureRect.new() background.name = "BattleLiveWorld" background.texture = BATTLE_OPENING_BACKGROUND background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(background) var shade := ColorRect.new() shade.name = "BattleLiveWorldShade" shade.color = Color(0.02, 0.045, 0.07, 0.06) shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) shade.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(shade) var canvas := Control.new() canvas.name = "BattleLiveCanvas" canvas.mouse_filter = Control.MOUSE_FILTER_PASS add_child(canvas) battle_live_canvas = canvas _layout_battle_live_canvas() func _layout_battle_live_canvas() -> void: if not is_instance_valid(battle_live_canvas): return var available := size if available.x <= 0.0 or available.y <= 0.0: return const DESIGN_ASPECT := 16.0 / 9.0 var canvas_size: Vector2 if available.x / available.y >= DESIGN_ASPECT: canvas_size = Vector2(available.y * DESIGN_ASPECT, available.y) else: canvas_size = Vector2(available.x, available.x / DESIGN_ASPECT) battle_live_canvas.position = (available - canvas_size) * 0.5 battle_live_canvas.size = canvas_size func _build_battle_live_overlay(company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: if not is_instance_valid(battle_live_canvas): return var art := BattleLiveArt.new() art.name = "BattleLiveArt" art.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) var view_event: Dictionary = Dictionary(view_data.get("event", battle_live_event)) var view_battle: Dictionary = Dictionary(view_data.get("battle", battle)) var source_id := String(view_event.get("actor_id", "")) var target_id := String(view_event.get("target_id", "")) var view_state := String(Dictionary(view_data.get("state", {})).get("name", battle_live_state)) art.set_event_state( view_state, String(view_event.get("kind", "")), _battle_live_actor_side(source_id, view_battle), _battle_live_actor_side(target_id, view_battle) ) art.set_presentation_state( bool(Dictionary(view_data.get("state", {})).get("paused", battle_paused)), float(Dictionary(view_data.get("state", {})).get("speed", battle_speed)), _battle_live_marker_anchors(view_battle, true), _battle_live_marker_anchors(view_battle, false), _battle_live_actor_anchor(source_id, view_battle, true), _battle_live_actor_anchor(target_id, view_battle, false) ) battle_live_canvas.add_child(art) var overlay := Control.new() overlay.name = "BattleLiveLayout" overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) overlay.mouse_filter = Control.MOUSE_FILTER_PASS battle_live_canvas.add_child(overlay) _build_battle_live_top(overlay, battle, view_data) _build_battle_live_field(overlay, company, battle, view_data) _build_battle_live_bottom(overlay, company, battle, view_data) if current_screen == "battle_inspect": _build_battle_inspect_overlay(overlay, company, battle, view_data) elif current_screen == "battle_pause_controls": _build_battle_pause_controls_overlay(overlay, company, battle, view_data) func _build_battle_live_top(parent: Control, battle: Dictionary, view_data: Dictionary = {}) -> void: var view_battle: Dictionary = Dictionary(view_data.get("battle", battle)) var view_enemies: Dictionary = Dictionary(view_data.get("enemies", {})) var view_commands: Dictionary = Dictionary(view_data.get("commands", {})) var party_count := Array(view_battle.get("front_to_rear_ids", battle.get("front_to_rear_ids", []))).size() var enemy_count := int(view_enemies.get("count", Array(view_battle.get("enemy_ids", battle.get("enemy_ids", []))).size())) var company_status := _battle_live_overlay_label(parent, "BattleLiveCompanyStatus", "%d" % party_count, Rect2(0.086, 0.018, 0.075, 0.075), 18, Color("#f5ead0")) company_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT company_status.tooltip_text = _tr("远征团 %d", "Company %d") % party_count var enemy_status := _battle_live_overlay_label(parent, "BattleLiveEnemyStatus", "%d" % enemy_count, Rect2(0.855, 0.018, 0.070, 0.075), 18, Color("#f5ead0")) enemy_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT enemy_status.tooltip_text = _tr("敌方 %d", "Enemy %d") % enemy_count var controls_locked := not bool(view_commands.get("pause_enabled", true)) and not bool(view_commands.get("settings_enabled", true)) var pause := _battle_live_button(parent, "BattleLivePause", "", Rect2(0.390, 0.0, 0.075, 0.14), toggle_battle_pause, false) pause.tooltip_text = _tr("继续自动观察", "Resume automatic observation") if battle_paused else _tr("暂停自动观察", "Pause automatic observation") _set_battle_live_button_disabled(pause, not bool(view_commands.get("pause_enabled", not controls_locked))) var speed := _battle_live_button(parent, "BattleLiveSpeed", "", Rect2(0.465, 0.0, 0.070, 0.14), cycle_battle_speed, false) speed.tooltip_text = _tr("切换 1x / 2x / 4x 展示速度", "Cycle 1x / 2x / 4x presentation speed") _set_battle_live_button_disabled(speed, not bool(view_commands.get("speed_enabled", not controls_locked))) var speed_value := _battle_live_overlay_label(parent, "BattleLiveSpeedValue", "x%d" % int(battle_speed), Rect2(0.503, 0.020, 0.030, 0.050), 8, Color("#24303a")) speed_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var inspect := _battle_live_button(parent, "BattleLiveInspect", "", Rect2(0.535, 0.0, 0.075, 0.14), _battle_live_toggle_inspect, false) inspect.tooltip_text = _tr("返回战场", "Return to battlefield") if battle_live_state == "inspect_handoff" else _tr("交给 BAT-003 检查原因", "Hand cause inspection to BAT-003") _set_battle_live_button_disabled(inspect, not bool(view_commands.get("inspect_enabled", true))) var settings_button := _battle_live_button(parent, "BattleLiveSettings", "", Rect2(0.872, 0.0, 0.075, 0.14), _battle_live_open_settings, false) settings_button.tooltip_text = _tr("设置", "Settings") _set_battle_live_button_disabled(settings_button, not bool(view_commands.get("settings_enabled", true))) func _build_battle_live_field(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var view_battle: Dictionary = Dictionary(view_data.get("battle", battle)) var view_party: Dictionary = Dictionary(view_data.get("party", {})) var view_enemies: Dictionary = Dictionary(view_data.get("enemies", {})) var view_event: Dictionary = Dictionary(view_data.get("event", battle_live_event)) var view_state: Dictionary = Dictionary(view_data.get("state", {})) var live_state := String(view_state.get("name", battle_live_state)) var objective_title := _battle_live_overlay_label(parent, "BattleLiveTitle", _tr("风镜初渡 · 自动", "WINDGLASS · AUTO"), Rect2(0.026, 0.148, 0.205, 0.020), 6, Color("#e8d39d")) objective_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var objective := _battle_live_overlay_label(parent, "BattleLiveObjective", _tr("目标 · 击破敌方队列", "BREAK THE ENEMY LINE"), Rect2(0.026, 0.170, 0.220, 0.028), 9, Color("#f5ead0")) objective.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var no_respawn := _battle_live_overlay_label(parent, "BattleLiveNoRespawn", _tr("无复活", "NO RESPAWN"), Rect2(0.026, 0.204, 0.090, 0.020), 5, Color("#f0c979")) no_respawn.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var no_reserve := _battle_live_overlay_label(parent, "BattleLiveNoReserve", _tr("无后备", "NO RESERVE"), Rect2(0.119, 0.204, 0.105, 0.020), 5, Color("#f0c979")) no_reserve.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var has_event := not view_event.is_empty() and live_state != "waiting_for_next_event" var opening_event := String(view_event.get("kind", "")) == "opening" var event_state := _battle_live_overlay_label(parent, "BattleLiveEventState", _battle_live_state_copy(), Rect2(0.555, 0.315, 0.17, 0.022), 6, Color("#e8d39d")) event_state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER event_state.visible = not opening_event and (has_event or live_state in ["paused", "inspect_handoff", "pause_controls_handoff", "outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"]) if live_state == "outcome_locked": # The immutable handoff is carried by the bottom boundary command; the # field keeps only the attached marker and compact OUT-001 identity. event_state.visible = false var event_title_text := _battle_live_event_short_copy() var event_title_rect := Rect2(0.370, 0.295, 0.26, 0.042) if not has_event else Rect2(0.555, 0.340, 0.17, 0.032) var event_title_size := 12 if not has_event else 7 var event_title := _battle_live_overlay_label(parent, "BattleLiveCurrentEvent", event_title_text, event_title_rect, event_title_size, Color("#f5ead0")) event_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER event_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART if battle_live_event.is_empty(): event_title.name = "BattleWaitingForNextEvent" event_title.visible = (not opening_event or view_event.is_empty()) and live_state not in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background"] var detail_text := _battle_live_event_detail(company, battle) var event_detail := _battle_live_overlay_label(parent, "BattleLiveEventDetail", detail_text, _battle_live_relation_rect(battle), 6, Color("#f6e3b0")) event_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER event_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART event_detail.visible = not detail_text.is_empty() and String(view_event.get("kind", "")) != "opening" and live_state not in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background"] var event_id := _battle_live_overlay_label(parent, "BattleLiveEventId", _battle_live_event_identity(), _battle_live_event_id_rect(battle), 6, Color("#d9c88f")) event_id.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER event_id.visible = not opening_event and live_state in ["event_forecast", "action_committed", "event_resolving"] event_id.tooltip_text = _battle_live_event_identity() var delta_text := _battle_live_delta_copy() var delta_size := 10 if delta_text == _tr("已验证", "VERIFIED") else 16 var delta := _battle_live_overlay_label(parent, "BattleLiveEventDelta", delta_text, _battle_live_delta_rect(battle), delta_size, _battle_live_delta_color()) delta.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER delta.visible = not delta_text.is_empty() and live_state == "event_resolved" if live_state == "outcome_locked": var outcome_label := _battle_live_overlay_label(parent, "BattleLiveOutcomeLabel", "OUT-001", Rect2(0.690, 0.318, 0.105, 0.026), 6, Color("#f6dfaa")) outcome_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _build_battle_live_diagnostic_actions(parent) var actors := Control.new() actors.name = "BattleActors" actors.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) actors.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(actors) var ids: Array = Array(view_party.get("front_to_rear_ids", view_battle.get("front_to_rear_ids", battle.get("front_to_rear_ids", [])))) var visual_slots := _battle_live_ally_slots() for visual_index in range(ids.size()): var source_index := ids.size() - 1 - visual_index var recruit_id := String(ids[source_index]) var actor := _battle_opening_actor(source_index, _recruit_by_id(company, recruit_id), battle, visual_slots[mini(visual_index, visual_slots.size() - 1)]) var position_label := actor.get_node_or_null("BattlePositionLabel") as Label var name_label := actor.get_node_or_null("BattleActorName") as Label if position_label != null: position_label.visible = false if name_label != null: name_label.visible = false _battle_live_add_event_contour(actor, recruit_id) actors.add_child(actor) var enemies := Control.new() enemies.name = "BattleEnemies" enemies.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) enemies.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(enemies) var enemy_ids: Array = Array(view_enemies.get("ids", view_battle.get("enemy_ids", battle.get("enemy_ids", [])))) var enemy_slots := _battle_live_enemy_slots() for enemy_index in range(enemy_ids.size()): var enemy_id_value := String(enemy_ids[enemy_index]) var enemy_actor := _battle_live_enemy(enemy_index, enemy_id_value, enemy_slots[mini(enemy_index, enemy_slots.size() - 1)]) _battle_live_add_event_contour(enemy_actor, enemy_id_value) enemies.add_child(enemy_actor) var recent_copy := _battle_live_recent_copy(view_battle) var recent := _battle_live_overlay_label(parent, "BattleLiveRecentEvents", recent_copy, Rect2(0.31, 0.765, 0.38, 0.022), 6, Color("#edf0dd")) recent.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER recent.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS # The approved rail has no diagnostic sentence between the field and cards. # Keep the node for the event-sequence binding, but expose it through the # inspect tooltip/state rather than a second text band. recent.visible = false func _build_battle_live_diagnostic_actions(parent: Control) -> void: if battle_live_state == "invalid_event_data": var retry := _battle_live_button(parent, "BattleLiveRetrySnapshot", _tr("重试快照", "RETRY SNAPSHOT"), Rect2(0.640, 0.180, 0.160, 0.140), _battle_live_retry_snapshot, true) retry.tooltip_text = _tr("验证最后安全快照与事件日志", "Validate the last safe snapshot and event journal") var safe_exit := _battle_live_button(parent, "BattleLiveSafeExit", _tr("安全退出", "SAFE EXIT"), Rect2(0.820, 0.180, 0.160, 0.140), _battle_live_safe_exit, false) safe_exit.tooltip_text = _tr("保留回执并返回标题", "Keep the receipt and return to Title") elif battle_live_state == "no_resumable_battle": var exit_button := _battle_live_button(parent, "BattleLiveSafeExit", _tr("安全退出", "SAFE EXIT"), Rect2(0.820, 0.180, 0.160, 0.140), _battle_live_safe_exit, true) exit_button.tooltip_text = _tr("不创建新战斗并返回标题", "Return to Title without fabricating a battle") elif battle_live_state == "interrupted_background": var title_button := _battle_live_button(parent, "BattleLiveSafeExit", _tr("返回标题", "RETURN TITLE"), Rect2(0.820, 0.180, 0.160, 0.140), _battle_live_safe_exit, false) title_button.tooltip_text = _tr("保留最后安全回执并返回标题", "Keep the last safe receipt and return to Title") func _battle_live_panel(parent: Control, node_name: String, rect: Rect2) -> Panel: var panel := Panel.new() panel.name = node_name _set_normalized_rect(panel, rect) panel.mouse_filter = Control.MOUSE_FILTER_STOP panel.add_theme_stylebox_override("panel", _flat_style(Color("#142534", 0.94), Color("#e2c875", 0.92), 2, 8)) parent.add_child(panel) return panel func _build_battle_inspect_overlay(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var panel := _battle_live_panel(parent, "BattleActorEventInspectPanel", Rect2(0.62, 0.14, 0.35, 0.70)) var title := _battle_live_overlay_label(panel, "BattleInspectTitle", _tr("BAT-003 · 角色 / 事件检查", "BAT-003 · ACTOR / EVENT INSPECT"), Rect2(0.06, 0.04, 0.88, 0.08), 10, Color("#f5ead0")) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var event: Dictionary = Dictionary(view_data.get("event", battle_live_event)) var view_observation: Dictionary = Dictionary(view_data.get("observation", battle_live_observation)) var source_id := String(event.get("actor_id", "")) var target_id := String(event.get("target_id", "")) var source := _battle_live_actor_name(company, battle, source_id) var target := _battle_live_actor_name(company, battle, target_id) var kind := _battle_live_event_short_copy() var delta := int(event.get("amount", 0)) var direction := String(Dictionary(event.get("state_delta", {})).get("direction", "none")) var delta_copy := _tr("无数值变化", "No numeric delta") if delta == 0 else ("%s%d" % ["-" if direction == "loss" else "+", delta]) var facts := _battle_live_overlay_label(panel, "BattleInspectFacts", _tr( "事件:%s\n来源:%s\n目标:%s\n变化:%s\n状态:%s\n事件 ID:%s", "Event: %s\nSource: %s\nTarget: %s\nDelta: %s\nState: %s\nEvent ID: %s" ) % [kind, source, target, delta_copy, _battle_live_state_copy(), _short_hash(event.get("event_id", "none"))], Rect2(0.07, 0.15, 0.86, 0.30), 8, Color("#f5ead0")) facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.vertical_alignment = VERTICAL_ALIGNMENT_TOP var cause := _battle_live_overlay_label(panel, "BattleInspectCause", _tr( "因果邻接:当前事件由稳定快照、目标关系和上一条事件顺序共同解释。装备贡献保持在 Recruit 身份与战斗快照中。", "Causal neighbors: this event is explained by the stable snapshot, target relation and prior event order. Equipment contribution remains bound to the Recruit identity and battle snapshot." ), Rect2(0.07, 0.47, 0.86, 0.18), 7, Color("#c6d8df")) cause.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART var hashes := _battle_live_overlay_label(panel, "BattleInspectHashes", _tr("快照 %s · 日志 %s", "Snapshot %s · Journal %s") % [_short_hash(battle.get("snapshot_hash", "")), _short_hash(view_observation.get("event_journal_hash", ""))], Rect2(0.07, 0.67, 0.86, 0.06), 6, Color("#9ec9dd")) hashes.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var close := _battle_live_button(panel, "BattleInspectClose", _tr("返回暂停战场", "Back to Paused Battle"), Rect2(0.07, 0.76, 0.42, 0.15), _battle_live_cancel_handoff, false) close.tooltip_text = _tr("关闭检查,不改变结果", "Close inspect without changing the outcome") var pin := _battle_live_button(panel, "BattleInspectPin", _tr("固定事件", "Pin Event"), Rect2(0.53, 0.76, 0.40, 0.15), _battle_inspect_pin_event, false) pin.tooltip_text = _tr("只改变本地检查焦点,不写入战斗回执", "Change local inspect focus only; no battle receipt is written") close.call_deferred("grab_focus") func _build_battle_pause_controls_overlay(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var panel := _battle_live_panel(parent, "BattlePauseControlsPanel", Rect2(0.24, 0.17, 0.52, 0.64)) var title := _battle_live_overlay_label(panel, "BattlePauseTitle", _tr("BAT-004 · 暂停 / 速度 / 日志", "BAT-004 · PAUSE / SPEED / LOG"), Rect2(0.06, 0.04, 0.88, 0.08), 11, Color("#f5ead0")) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var view_state: Dictionary = Dictionary(view_data.get("state", {})) var status := _battle_live_overlay_label(panel, "BattlePauseStatus", _tr("时间线已冻结 · 当前速度 %dx", "Timeline frozen · current speed %dx") % int(float(view_state.get("speed", battle_speed))), Rect2(0.06, 0.14, 0.88, 0.07), 8, Color("#f0ca75")) status.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT var log_text := _battle_pause_log_text(battle) var log := _battle_live_overlay_label(panel, "BattlePauseLog", log_text, Rect2(0.06, 0.24, 0.88, 0.32), 7, Color("#d7e5e7")) log.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART log.vertical_alignment = VERTICAL_ALIGNMENT_TOP var resume := _battle_live_button(panel, "BattlePauseResume", _tr("继续观察", "Resume"), Rect2(0.06, 0.61, 0.26, 0.15), _battle_pause_resume, true) var speed := _battle_live_button(panel, "BattlePauseSpeed", _tr("速度 %dx", "Speed %dx") % int(battle_speed), Rect2(0.35, 0.61, 0.22, 0.15), _battle_pause_cycle_speed, false) var log_button := _battle_live_button(panel, "BattlePauseLogToggle", _tr("收起日志", "Hide Log") if battle_pause_log_open else _tr("查看日志", "View Log"), Rect2(0.60, 0.61, 0.22, 0.15), _battle_pause_toggle_log, false) var settings_button := _battle_live_button(panel, "BattlePauseSettings", _tr("设置", "Settings"), Rect2(0.06, 0.78, 0.21, 0.14), _battle_live_open_settings, false) var retreat := _battle_live_button(panel, "BattlePauseRetreat", _tr("撤退检查", "Retreat Review"), Rect2(0.30, 0.78, 0.27, 0.14), _battle_pause_retreat_boundary, false) var close := _battle_live_button(panel, "BattlePauseClose", _tr("返回战场", "Back to Battle"), Rect2(0.60, 0.78, 0.27, 0.14), _battle_live_cancel_handoff, false) resume.tooltip_text = _tr("恢复自动时间线", "Resume the automatic timeline") retreat.tooltip_text = _tr("BAT-005 负责真正的撤退确认;此处不创建回执", "BAT-005 owns the actual retreat confirmation; this page creates no receipt") settings_button.tooltip_text = _tr("返回时仍保持暂停", "Playback remains paused when returning") close.call_deferred("grab_focus") func _battle_pause_log_text(battle: Dictionary) -> String: var events: Array = battle.get("phase_events", []) if events.is_empty(): return _tr("暂无已提交事件。\n等待下一个事件。", "No committed events yet.\nWaiting for next event.") var lines: Array[String] = [] var start := maxi(0, events.size() - 4) for event in events.slice(start, events.size()): lines.append("%02d · %s" % [int(event.get("event_tick", 0)), _battle_event_text(String(event.get("caption_id", "")))]) return _tr("最近事件\n", "Recent events\n") + "\n".join(lines) if battle_pause_log_open else _tr("日志已折叠。当前事件:%s", "Log collapsed. Current event: %s") % _battle_live_event_short_copy() func _battle_pause_resume() -> void: if current_screen != "battle_pause_controls": return current_screen = "battle" battle_live_state = battle_live_resume_state battle_live_notice = "" battle_paused = false _render_battle_live() _schedule_battle_playback(battle_playback_generation) func _battle_pause_cycle_speed() -> void: if current_screen != "battle_pause_controls": return if is_equal_approx(battle_speed, 1.0): battle_speed = 2.0 elif is_equal_approx(battle_speed, 2.0): battle_speed = 4.0 else: battle_speed = 1.0 _render_battle_live() func _battle_pause_toggle_log() -> void: if current_screen != "battle_pause_controls": return battle_pause_log_open = not battle_pause_log_open _render_battle_live() func _battle_pause_retreat_boundary() -> void: if current_screen != "battle_pause_controls": return battle_live_notice = _tr("BAT-005 负责撤退确认;此处保持暂停且不创建撤退回执。", "BAT-005 owns retreat confirmation; playback stays paused and no retreat receipt is created here.") _render_battle_live() func _battle_inspect_pin_event() -> void: if current_screen != "battle_inspect": return battle_live_notice = _tr("事件已固定为本地检查焦点;战斗回执未改变。", "Event pinned as a local inspect focus; the battle receipt is unchanged.") _render_battle_live() func _build_battle_live_bottom(parent: Control, company: Dictionary, battle: Dictionary, view_data: Dictionary = {}) -> void: var view_battle: Dictionary = Dictionary(view_data.get("battle", battle)) var view_party: Dictionary = Dictionary(view_data.get("party", {})) var view_observation: Dictionary = Dictionary(view_data.get("observation", battle_live_observation)) var view_state: Dictionary = Dictionary(view_data.get("state", {})) var view_commands: Dictionary = Dictionary(view_data.get("commands", {})) var live_state := String(view_state.get("name", battle_live_state)) var tick := int(view_observation.get("next_phase_index", battle_live_observation.get("next_phase_index", 0))) var total := int(view_battle.get("total_phases", battle.get("total_phases", 9))) var rule := _battle_live_overlay_label(parent, "BattleLiveRule", _tr("自动", "AUTO"), Rect2(0.055, 0.842, 0.075, 0.023), 7, Color("#e8d39d")) rule.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var phase := _battle_live_overlay_label(parent, "BattleLiveTick", "%02d / %02d" % [tick, total], Rect2(0.052, 0.870, 0.088, 0.060), 14, Color("#f5ead0")) phase.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var snapshot := _battle_live_overlay_label(parent, "BattleLiveSnapshotHash", _short_hash(view_battle.get("snapshot_hash", "")), Rect2(0.018, 0.952, 0.125, 0.020), 6, Color("#9ec9dd")) snapshot.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER snapshot.visible = false var ids: Array = Array(view_party.get("front_to_rear_ids", view_battle.get("front_to_rear_ids", battle.get("front_to_rear_ids", [])))) for visual_index in range(ids.size()): var source_index := ids.size() - 1 - visual_index var recruit := _recruit_by_id(company, String(ids[source_index])) parent.add_child(_battle_live_state_cell(source_index, recruit, battle, _battle_live_bottom_card_rect(visual_index, ids.size()))) var next_text := _tr("撤退", "RETREAT") var next_action := Callable(self, "_battle_live_open_retreat_entry") var next_disabled := not bool(view_commands.get("next_boundary_enabled", true)) var next_tooltip := _tr("BAT-004/BAT-005 负责控制与确认", "BAT-004/BAT-005 own controls and confirmation") match live_state: "outcome_locked": next_text = "OUT-001" next_action = Callable(self, "_battle_live_commit_outcome") next_disabled = false next_tooltip = _tr("OUT-001 负责结果诊断", "OUT-001 owns result diagnosis") "inspect_handoff", "pause_controls_handoff": next_text = _tr("返回", "RETURN") next_action = Callable(self, "_battle_live_cancel_handoff") next_disabled = false next_tooltip = _tr("返回已冻结的战场状态", "Return to the frozen battlefield state") "interrupted_background": next_text = "BAT-006" next_action = Callable(self, "_battle_live_open_bat006") next_disabled = false next_tooltip = _tr("BAT-006 负责中断恢复检查", "BAT-006 owns interruption review") var next_button := _battle_live_button(parent, "BattleLiveNextBoundary", next_text, Rect2(0.895, 0.810, 0.105, 0.180), next_action, false) next_button.tooltip_text = next_tooltip _set_battle_live_button_disabled(next_button, next_disabled) var next_label := next_button.get_node_or_null("ButtonLabel") as Label if next_label != null: next_label.visible = battle_live_state not in ["waiting_for_next_event", "event_forecast", "action_committed", "event_resolving", "event_resolved", "paused"] if next_label.visible: next_label.text = next_text next_label.anchor_left = 0.02 next_label.anchor_top = 0.68 next_label.anchor_right = 0.98 next_label.anchor_bottom = 0.94 next_label.add_theme_font_size_override("font_size", _battle_live_text_size(6)) func _battle_live_enemy(index: int, enemy_id: String, rect: Rect2) -> Control: var enemy := Control.new() enemy.name = "BattleEnemy%d" % index _set_normalized_rect(enemy, rect) enemy.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.set_meta("enemy_id", enemy_id) var shadow := ColorRect.new() shadow.anchor_left = 0.14 shadow.anchor_top = 0.82 shadow.anchor_right = 0.86 shadow.anchor_bottom = 0.88 shadow.color = Color("#21141b", 0.40) shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(shadow) var portrait := TextureRect.new() portrait.name = "BattleEnemyPortrait" portrait.texture = BATTLE_OPENING_ENEMY portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.04 portrait.anchor_top = 0.0 portrait.anchor_right = 0.96 portrait.anchor_bottom = 0.80 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(portrait) var hp_back := ColorRect.new() hp_back.name = "BattleEnemyHPBack" hp_back.anchor_left = 0.10 hp_back.anchor_top = 0.755 hp_back.anchor_right = 0.90 hp_back.anchor_bottom = 0.835 hp_back.color = Color("#11161a", 0.92) hp_back.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(hp_back) var hp := ColorRect.new() hp.name = "BattleEnemyHP" hp.anchor_left = 0.13 hp.anchor_top = 0.775 hp.anchor_right = 0.87 hp.anchor_bottom = 0.815 hp.color = Color("#d95f59") hp.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy.add_child(hp) var label := _label(_content_name("enemies", enemy_id), _battle_live_text_size(8), Color("#ffe5d1")) label.name = "BattleEnemyName" label.anchor_top = 0.84 label.anchor_right = 1.0 label.anchor_bottom = 1.0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.visible = false enemy.add_child(label) return enemy func _battle_live_state_cell(position: int, recruit: Dictionary, battle: Dictionary, rect: Rect2) -> Control: var cell := Control.new() cell.name = "BattleLiveStateCell%d" % position _set_normalized_rect(cell, rect) cell.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.clip_contents = true cell.tooltip_text = _recruit_name(recruit) var inner := Panel.new() inner.name = "BattleLiveStateInset" inner.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) inner.mouse_filter = Control.MOUSE_FILTER_IGNORE var inner_style := StyleBoxFlat.new() inner_style.bg_color = Color("#243548", 0.16) inner_style.border_color = Color("#d9b461", 0.42) inner_style.set_border_width_all(1) inner_style.set_corner_radius_all(2) inner.add_theme_stylebox_override("panel", inner_style) cell.add_child(inner) var portrait := TextureRect.new() portrait.name = "BattleLiveStatePortrait" portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE # The approved rail is portrait-led. Enlarge the source while keeping the # head and profession silhouette inside the authored card instead of using a # hard cover crop that can cut a face at narrow card widths. portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.025 portrait.anchor_top = -0.095 portrait.anchor_right = 0.975 portrait.anchor_bottom = 0.965 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(portrait) var position_label := _label("%d" % (position + 1), _battle_live_text_size(8), Color("#f5ead0")) position_label.name = "BattleLiveStatePosition" position_label.anchor_left = 0.015 position_label.anchor_top = 0.015 position_label.anchor_right = 0.24 position_label.anchor_bottom = 0.245 position_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER position_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER position_label.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(position_label) var identity := _label(_recruit_name(recruit), _battle_live_text_size(6), Color("#f5ead0")) identity.name = "BattleLiveStateIdentity" identity.anchor_left = 0.20 identity.anchor_top = 0.705 identity.anchor_right = 0.95 identity.anchor_bottom = 0.735 identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS identity.mouse_filter = Control.MOUSE_FILTER_IGNORE identity.tooltip_text = _recruit_name(recruit) # The portrait rail carries identity visually; keep the full name available # to focus/tooltip users without turning the rail into a text list. identity.visible = false cell.add_child(identity) var role_plate := Panel.new() role_plate.name = "BattleLiveStateRolePlate" role_plate.anchor_left = 0.765 role_plate.anchor_top = 0.505 role_plate.anchor_right = 0.950 role_plate.anchor_bottom = 0.700 role_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE var role_style := StyleBoxFlat.new() role_style.bg_color = Color("#0d1721", 0.70) role_style.border_color = Color(InitialCompanyArt.accent_for(recruit), 0.78) role_style.set_border_width_all(1) role_style.set_corner_radius_all(2) role_plate.add_theme_stylebox_override("panel", role_style) cell.add_child(role_plate) var role := _label(_battle_live_role_glyph(recruit), _battle_live_text_size(10), InitialCompanyArt.accent_for(recruit)) role.name = "BattleLiveStateRole" role.anchor_left = 0.765 role.anchor_top = 0.505 role.anchor_right = 0.950 role.anchor_bottom = 0.700 role.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER role.vertical_alignment = VERTICAL_ALIGNMENT_CENTER role.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(role) var fallen := String(recruit.get("recruit_id", "")) in Array(battle.get("knocked_out_ids", [])) var state := _label(_tr("离场", "FALLEN") if fallen else _tr("就绪", "READY"), _battle_live_text_size(5), Color("#e98b83") if fallen else Color("#8fd2a2")) state.name = "BattleLiveStateStatus" state.anchor_left = 0.50 state.anchor_top = 0.025 state.anchor_right = 0.95 state.anchor_bottom = 0.18 state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER state.mouse_filter = Control.MOUSE_FILTER_IGNORE state.visible = fallen cell.add_child(state) var hp := ColorRect.new() hp.name = "BattleLiveStateHP" hp.anchor_left = 0.070 hp.anchor_top = 0.745 hp.anchor_right = 0.070 if fallen else 0.930 hp.anchor_bottom = 0.815 hp.color = Color("#73bd64") hp.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(hp) var resource := ColorRect.new() resource.name = "BattleLiveStateResource" resource.anchor_left = 0.070 resource.anchor_top = 0.862 resource.anchor_right = 0.070 if fallen else 0.820 resource.anchor_bottom = 0.932 resource.color = Color("#4f9ee0") if not fallen else Color("#5a6268") resource.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(resource) return cell func _battle_live_bottom_card_rect(visual_index: int, count: int) -> Rect2: var card_count := maxi(1, count) var card_left := 0.170 var card_right := 0.885 var card_gap := clampf(0.012 - float(card_count) * 0.0008, 0.006, 0.012) var card_width := (card_right - card_left - card_gap * float(card_count - 1)) / float(card_count) return Rect2(card_left + visual_index * (card_width + card_gap), 0.812, card_width, 0.175) func _battle_live_role_glyph(recruit: Dictionary) -> String: match String(recruit.get("profession_id", "")): "PF-B01", "PF-B08": return "◇" "PF-B02", "PF-B03", "PF-B04": return "✦" "PF-B05", "PF-B06", "PF-B10": return "✧" "PF-B07", "PF-B12": return "+" "PF-B09", "PF-B11": return "□" return "·" func _battle_live_add_event_contour(actor: Control, actor_id: String) -> void: if battle_live_event.is_empty(): return var source_id := String(battle_live_event.get("actor_id", "")) var target_id := String(battle_live_event.get("target_id", "")) if actor_id != source_id and actor_id != target_id: return var contour := Panel.new() contour.name = "BattleLiveSourceContour" if actor_id == source_id else "BattleLiveTargetContour" contour.anchor_left = 0.08 contour.anchor_top = 0.735 contour.anchor_right = 0.92 contour.anchor_bottom = 0.845 contour.mouse_filter = Control.MOUSE_FILTER_IGNORE var style := StyleBoxFlat.new() style.bg_color = Color("#f0c96d", 0.08) if actor_id == source_id else Color("#e36b66", 0.10) style.border_color = Color("#f0c96d") if actor_id == source_id else Color("#e36b66") style.set_border_width_all(2) style.set_corner_radius_all(2) contour.add_theme_stylebox_override("panel", style) # Target rings and subject markers are rendered by BattleLiveArt. Do not add # a second rectangular selection card around an actor's feet. contour.visible = false actor.add_child(contour) func _battle_live_ally_slots() -> Array[Rect2]: return [ Rect2(0.045, 0.475, 0.120, 0.270), Rect2(0.165, 0.465, 0.120, 0.285), Rect2(0.285, 0.455, 0.120, 0.300), Rect2(0.405, 0.440, 0.130, 0.320), ] func _battle_live_enemy_slots() -> Array[Rect2]: return [ Rect2(0.600, 0.475, 0.110, 0.250), Rect2(0.720, 0.430, 0.120, 0.295), Rect2(0.845, 0.335, 0.140, 0.395), ] func _battle_live_marker_anchors(battle: Dictionary, allies: bool) -> Array[Vector2]: var result: Array[Vector2] = [] var ids: Array = battle.get("front_to_rear_ids", []) if allies else battle.get("enemy_ids", []) var slots: Array[Rect2] = _battle_live_ally_slots() if allies else _battle_live_enemy_slots() for visual_index in range(ids.size()): var slot: Rect2 = slots[mini(visual_index, slots.size() - 1)] result.append(slot.position + Vector2(slot.size.x * 0.50, slot.size.y * 0.055)) return result func _battle_live_actor_anchor(actor_id: String, battle: Dictionary, source_fallback: bool) -> Vector2: var ally_ids: Array = battle.get("front_to_rear_ids", []) var ally_source_index := ally_ids.find(actor_id) if ally_source_index >= 0: var ally_visual_index := ally_ids.size() - 1 - ally_source_index var ally_slots := _battle_live_ally_slots() var ally_slot: Rect2 = ally_slots[mini(ally_visual_index, ally_slots.size() - 1)] return ally_slot.position + Vector2(ally_slot.size.x * 0.50, ally_slot.size.y * 0.58) var enemy_ids: Array = battle.get("enemy_ids", []) var enemy_index := enemy_ids.find(actor_id) if enemy_index >= 0: var enemy_slots := _battle_live_enemy_slots() var enemy_slot: Rect2 = enemy_slots[mini(enemy_index, enemy_slots.size() - 1)] return enemy_slot.position + Vector2(enemy_slot.size.x * 0.50, enemy_slot.size.y * 0.58) return Vector2(0.47, 0.56) if source_fallback else Vector2(0.67, 0.56) func _battle_live_relation_rect(battle: Dictionary) -> Rect2: var source := _battle_live_actor_anchor(String(battle_live_event.get("actor_id", "")), battle, true) var target := _battle_live_actor_anchor(String(battle_live_event.get("target_id", "")), battle, false) var center := (source + target) * 0.5 return Rect2(clampf(center.x - 0.105, 0.34, 0.66), clampf(center.y - 0.105, 0.40, 0.58), 0.21, 0.030) func _battle_live_event_id_rect(battle: Dictionary) -> Rect2: var target := _battle_live_actor_anchor(String(battle_live_event.get("target_id", "")), battle, false) return Rect2(clampf(target.x - 0.075, 0.34, 0.82), clampf(target.y - 0.285, 0.24, 0.36), 0.15, 0.025) func _battle_live_delta_rect(battle: Dictionary) -> Rect2: var target := _battle_live_actor_anchor(String(battle_live_event.get("target_id", "")), battle, false) return Rect2(clampf(target.x - 0.050, 0.04, 0.90), clampf(target.y - 0.235, 0.26, 0.42), 0.10, 0.060) func _battle_live_actor_side(actor_id: String, battle: Dictionary) -> String: if actor_id in Array(battle.get("front_to_rear_ids", [])): return "ally" if actor_id in Array(battle.get("enemy_ids", [])): return "enemy" return "" func _battle_live_state_copy() -> String: match battle_live_state: "event_forecast": return _tr("事件预告", "FORECAST") "action_committed": return _tr("行动已提交", "ACTION COMMITTED") "event_resolving": return _tr("事件结算中", "RESOLVING") "event_resolved": return _tr("事件已结算", "EVENT RESOLVED") "paused": return _tr("已暂停", "PAUSED") "inspect_handoff": return "BAT-003" "pause_controls_handoff": return "BAT-004 / BAT-005" "outcome_locked": return _tr("结果已锁定", "OUTCOME LOCKED") "invalid_event_data": return _tr("事件数据无效", "INVALID EVENT DATA") "snapshot_retrying": return _tr("正在验证快照", "VALIDATING SNAPSHOT") "loading_resume": return _tr("正在恢复", "LOADING RESUME") "interrupted_background": return _tr("观察已中断", "OBSERVATION INTERRUPTED") "no_resumable_battle": return _tr("无法恢复", "NO RESUMABLE BATTLE") return _tr("等待事件", "WAITING") func _battle_live_event_title() -> String: match battle_live_state: "paused": return _tr("自动时间线已冻结", "Automatic timeline frozen") "inspect_handoff": return _tr("完整原因检查属于 BAT-003", "Full cause inspection belongs to BAT-003") "pause_controls_handoff": return _tr("完整控制与撤退确认属于后续页面", "Full controls and retreat confirmation belong to later pages") "outcome_locked": return _tr("OUT-001 是下一边界", "OUT-001 is the next boundary") "invalid_event_data": return _tr("播放已阻止", "Playback blocked") "snapshot_retrying": return _tr("验证最后安全日志", "Validating the last safe journal") "loading_resume": return _tr("原快照验证通过", "Original snapshot validated") "interrupted_background": return _tr("BAT-006 检查后才能继续", "BAT-006 review is required before resume") "no_resumable_battle": return _tr("不会创建替代战斗", "No replacement battle will be created") if battle_live_event.is_empty() or battle_live_state == "waiting_for_next_event": return _tr("等待下一个事件", "Waiting for next event") return _battle_event_text(String(battle_live_event.get("caption_id", ""))) func _battle_live_event_short_copy() -> String: if battle_live_event.is_empty() or battle_live_state == "waiting_for_next_event": return _tr("等待下一个事件", "Waiting for next event") match String(battle_live_event.get("kind", "")): "telegraph": return _tr("威胁", "THREAT") "guard": return _tr("护卫", "GUARD") "ranged": return _tr("远射", "RANGED") "magic": return _tr("魔法", "MAGIC") "healing": return _tr("治疗", "HEAL") "damage": return _tr("伤害", "DAMAGE") "knockout": return _tr("击倒", "KNOCKOUT") "guarded_recovery": return _tr("守住", "HELD") "result": return _tr("结果", "OUTCOME") return _tr("事件", "EVENT") func _battle_live_event_detail(company: Dictionary, battle: Dictionary) -> String: if not battle_live_notice.is_empty(): return battle_live_notice match battle_live_state: "waiting_for_next_event": return "" "paused": return _tr("速度 %dx · 事件 %d/9", "SPEED %dx · EVENT %d/9") % [int(battle_speed), int(battle_live_observation.get("next_phase_index", 0))] "inspect_handoff": return _tr("事件 %s · 快照 %s", "EVENT %s · SNAPSHOT %s") % [_short_hash(battle_live_event.get("event_id", "none")), _short_hash(battle.get("snapshot_hash", ""))] "pause_controls_handoff": return _tr("未创建撤退回执;时间线保持冻结", "No retreat receipt created; timeline remains frozen") "outcome_locked": return "" "invalid_event_data": return _battle_error_text(battle_live_error_code) "snapshot_retrying": return _tr("保持原快照、事件顺序与日志哈希", "Preserving the original snapshot, event order and journal hash") "loading_resume": return _tr("正在恢复最后安全事件边界", "Restoring the last safe event boundary") "interrupted_background": return _tr("最后提交事件 %d · 先前速度 %dx", "LAST COMMITTED %d · PRIOR SPEED %dx") % [int(battle_live_interruption.get("last_committed_tick", 0)), int(battle_live_interruption.get("prior_speed", 1.0))] "no_resumable_battle": return _battle_error_text(battle_live_error_code) if battle_live_event.is_empty(): return "" var source_id := String(battle_live_event.get("actor_id", "")) var target_id := String(battle_live_event.get("target_id", "")) if source_id.is_empty() and target_id.is_empty(): return _tr("事件边界已验证", "Event boundary verified") var source := _battle_live_actor_short_name(battle, source_id, true) var target := _battle_live_actor_short_name(battle, target_id, false) var amount := int(battle_live_event.get("amount", 0)) if source_id.is_empty(): return _tr("目标 %s", "TARGET %s") % target if target_id.is_empty(): return _tr("来源 %s", "SOURCE %s") % source var value_copy := "" if amount != 0: var direction := String(Dictionary(battle_live_event.get("state_delta", {})).get("direction", "none")) value_copy = " · %s%d" % ["-" if direction == "loss" else "+", amount] return _tr("%s → %s%s", "%s -> %s%s") % [source, target, value_copy] func _battle_live_actor_short_name(battle: Dictionary, actor_id: String, source: bool) -> String: if actor_id.is_empty(): return _tr("无", "NONE") var ally_ids: Array = battle.get("front_to_rear_ids", []) var ally_index := ally_ids.find(actor_id) if ally_index >= 0: return _tr("%d号", "P%d") % ally_index var enemy_ids: Array = battle.get("enemy_ids", []) var enemy_index := enemy_ids.find(actor_id) if enemy_index >= 0: return _tr("敌%d", "E%d") % (enemy_index + 1) return _tr("来源", "SOURCE") if source else _tr("目标", "TARGET") func _battle_live_event_identity() -> String: if battle_live_state == "invalid_event_data": return "ERROR %s" % battle_live_error_code if battle_live_state == "interrupted_background": return "SNAPSHOT %s" % _short_hash(battle_live_interruption.get("snapshot_hash", "")) if battle_live_state == "outcome_locked": return _tr("结果已锁定", "OUTCOME LOCKED") if battle_live_event.is_empty() or battle_live_state == "waiting_for_next_event": return _tr("回执已验证", "RECEIPT VERIFIED") return _tr("事件 %d", "EVENT %d") % int(battle_live_event.get("event_tick", 0)) func _battle_live_delta_copy() -> String: if battle_live_state != "event_resolved" or battle_live_event.is_empty(): return "" var amount := int(battle_live_event.get("amount", 0)) if amount == 0: return _tr("已验证", "VERIFIED") var direction := String(Dictionary(battle_live_event.get("state_delta", {})).get("direction", "none")) return "%s%d" % ["-" if direction == "loss" else "+", amount] func _battle_live_delta_color() -> Color: var direction := String(Dictionary(battle_live_event.get("state_delta", {})).get("direction", "none")) if direction == "loss": return Color("#f07b71") if direction == "gain": return Color("#83d3a1") return Color("#e8d39d") func _battle_live_actor_name(company: Dictionary, battle: Dictionary, actor_id: String) -> String: if actor_id.is_empty(): return _tr("无", "None") if actor_id in Array(battle.get("front_to_rear_ids", [])): return _recruit_name(_recruit_by_id(company, actor_id)) var enemy_name := _content_name("enemies", actor_id) return enemy_name if not enemy_name.is_empty() else _tr("敌方单位", "Enemy unit") func _battle_live_recent_copy(battle: Dictionary) -> String: var events: Array = battle.get("phase_events", []) if events.is_empty(): return "" if events.size() == 1 and String(events[0].get("kind", "")) == "opening": return "" var copies: Array[String] = [] for event in events.slice(maxi(0, events.size() - 2), events.size()): copies.append(_battle_event_text(String(event.get("caption_id", "")))) return " · ".join(copies) func _battle_live_open_settings() -> void: if battle_live_state not in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "interrupted_background", "no_resumable_battle"]: if battle_live_state != "paused": battle_live_resume_state = battle_live_state battle_live_state = "paused" battle_live_notice = _tr("从设置返回后保持暂停", "Playback remains paused after Settings") battle_paused = true battle_playback_generation += 1 _clear_battle_playback_timer() settings_caller = "battle_pause_controls" if current_screen == "battle_pause_controls" else "battle_live" show_settings() func _battle_live_return_from_settings() -> void: if battle_live_state != "outcome_locked": battle_live_state = "paused" battle_live_notice = _tr("设置已关闭;自动时间线仍暂停", "Settings closed; the automatic timeline remains paused") battle_paused = true current_screen = "battle_pause_controls" if settings_caller == "battle_pause_controls" else "battle" _render_battle_live() func _battle_live_overlay_label(parent: Control, node_name: String, text_value: String, rect: Rect2, font_size: int, color: Color) -> Label: var label := _battle_opening_overlay_label(parent, node_name, text_value, rect, font_size, color) label.add_theme_font_size_override("font_size", _battle_live_text_size(font_size)) return label func _battle_live_button(parent: Control, node_name: String, text_value: String, rect: Rect2, callback: Callable, primary: bool) -> Button: var button := _battle_opening_button(parent, node_name, text_value, rect, callback, primary) var label := button.get_node_or_null("ButtonLabel") as Label if label != null: label.add_theme_font_size_override("font_size", _battle_live_text_size(10 if not primary else 12)) if node_name in ["BattleLivePause", "BattleLiveSpeed", "BattleLiveInspect", "BattleLiveSettings"]: for state_name in ["normal", "hover", "pressed", "focus", "disabled", "hover_pressed"]: button.add_theme_stylebox_override(state_name, _flat_style(Color.TRANSPARENT, Color.TRANSPARENT, 0, 0)) if label != null: label.visible = false elif node_name == "BattleLiveNextBoundary": for state_name in ["normal", "pressed", "disabled", "hover_pressed"]: button.add_theme_stylebox_override(state_name, _flat_style(Color.TRANSPARENT, Color.TRANSPARENT, 0, 0)) button.add_theme_stylebox_override("hover", _flat_style(Color("#d8b45c", 0.10), Color("#e4c979", 0.55), 1, 3)) button.add_theme_stylebox_override("focus", _flat_style(Color("#d8b45c", 0.12), Color("#f3db99", 0.85), 2, 3)) if label != null: label.anchor_left = 0.08 label.anchor_top = 0.62 label.anchor_right = 0.92 label.anchor_bottom = 0.92 label.add_theme_font_size_override("font_size", _battle_live_text_size(7)) return button func _set_battle_live_button_disabled(button: Button, disabled: bool) -> void: button.disabled = disabled button.mouse_default_cursor_shape = Control.CURSOR_FORBIDDEN if disabled else Control.CURSOR_POINTING_HAND var label := button.get_node_or_null("ButtonLabel") as Label if label != null: label.add_theme_color_override("font_color", Color("#9aa1a4") if disabled else Color("#f5ead0")) func _battle_live_wire_focus() -> void: var focus_nodes: Array[Control] = [] for node_name in [ "BattleLiveSettings", "BattleLiveInspect", "BattleLivePause", "BattleLiveSpeed", "BattleLiveRetrySnapshot", "BattleLiveSafeExit", "BattleLiveNextBoundary", ]: var control := find_child(node_name, true, false) as Control if control != null and control.visible and (not (control is BaseButton) or not (control as BaseButton).disabled): focus_nodes.append(control) if focus_nodes.size() < 2: return for index in range(focus_nodes.size()): focus_nodes[index].focus_next = focus_nodes[index].get_path_to(focus_nodes[(index + 1) % focus_nodes.size()]) focus_nodes[index].focus_previous = focus_nodes[index].get_path_to(focus_nodes[(index - 1 + focus_nodes.size()) % focus_nodes.size()]) func _battle_live_text_size(design_size: int) -> int: var canvas_height := battle_live_canvas.size.y if is_instance_valid(battle_live_canvas) else size.y var height_ratio := clampf(canvas_height / 360.0, 1.0, 2.0) var canvas_scale := lerpf(1.0, height_ratio, 0.5) var text_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))) return maxi(6, int(round(float(design_size) * canvas_scale * text_scale))) func _battle_live_toggle_inspect() -> void: if battle_live_state == "inspect_handoff": _battle_live_cancel_handoff() return if battle_live_state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle", "pause_controls_handoff"]: return if battle_live_state != "paused": battle_live_resume_state = battle_live_state battle_paused = true battle_playback_generation += 1 _clear_battle_playback_timer() battle_live_state = "inspect_handoff" battle_live_notice = _tr("时间线已冻结;当前事件可检查。", "Timeline frozen; the current event is inspectable.") current_screen = "battle_inspect" _render_battle_live() func _battle_live_open_retreat_entry() -> void: if battle_live_state in ["action_committed", "event_resolving", "invalid_event_data", "snapshot_retrying", "loading_resume", "outcome_locked", "interrupted_background", "no_resumable_battle"]: return if battle_live_state != "paused": battle_live_resume_state = battle_live_state battle_paused = true battle_playback_generation += 1 _clear_battle_playback_timer() battle_live_state = "pause_controls_handoff" battle_live_notice = _tr("BAT-004 负责完整控制,BAT-005 负责撤退确认;未创建撤退回执", "BAT-004 owns full controls and BAT-005 owns retreat confirmation; no retreat receipt was created") current_screen = "battle_pause_controls" _render_battle_live() func _battle_live_cancel_handoff() -> void: if battle_live_state not in ["inspect_handoff", "pause_controls_handoff"]: return battle_live_state = "paused" battle_live_notice = _tr("已返回最后安全事件边界", "Returned to the last safe event boundary") battle_paused = true current_screen = "battle" _render_battle_live() func _battle_live_outcome_handoff() -> void: if battle_live_state != "outcome_locked": return battle_live_notice = _tr("OUT-001 负责结果诊断;奖励、恢复与下一阶段尚未执行", "OUT-001 owns result diagnosis; reward, recovery and next stage have not run") _render_battle_live() func _battle_live_commit_outcome() -> void: if battle_live_state != "outcome_locked": return battle_service.company_path = initial_company_service.company_path var settled: Dictionary = battle_service.settle_observation_outcome(battle_operation_id) if not settled.ok: battle_live_notice = _battle_error_text(String(settled.get("code", "OUTCOME_SETTLEMENT_FAILED"))) _render_battle_live() return guild_company = settled.company.duplicate(true) battle_result_diagnosis_open = false battle_reward_selected_id = "" battle_reward_notice = "" show_battle_result(guild_company) func _battle_live_open_bat006() -> void: if battle_live_state != "interrupted_background": return battle_live_notice = _tr("BAT-006 必须验证原快照后才能返回移动中的战场", "BAT-006 must validate the original snapshot before returning to a moving field") _render_battle_live() func _battle_live_interrupt(reason := "background") -> void: if battle_live_state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"]: return if battle_live_state != "paused": battle_live_resume_state = battle_live_state battle_live_interruption = { "reason": reason, "last_committed_tick": int(battle_live_observation.get("next_phase_index", 0)), "snapshot_id": String(battle_live_observation.get("snapshot_id", "")), "snapshot_hash": String(battle_live_observation.get("snapshot_hash", "")), "prior_speed": battle_speed, "prior_paused": battle_paused, } battle_paused = true battle_playback_generation += 1 _clear_battle_playback_timer() battle_live_state = "interrupted_background" battle_live_notice = "" _render_battle_live() func _battle_live_retry_snapshot() -> void: if battle_live_state != "invalid_event_data": return battle_live_state = "snapshot_retrying" battle_live_notice = "" battle_paused = true _render_battle_live() call_deferred("_battle_live_complete_snapshot_retry") func _battle_live_complete_snapshot_retry() -> void: battle_service.company_path = initial_company_service.company_path var retried: Dictionary = battle_service.retry_observation_snapshot( battle_operation_id, String(battle_live_observation.get("snapshot_hash", "")), String(battle_live_observation.get("event_journal_hash", "")) ) if not retried.ok: battle_live_error_code = String(retried.get("code", "OBSERVATION_RETRY_FAILED")) battle_live_state = "no_resumable_battle" if battle_live_error_code in ["BATTLE_NOT_FOUND", "OBSERVATION_NOT_STARTED", "OBSERVATION_NOT_ACTIVE"] else "invalid_event_data" _render_battle_live() return guild_company = retried.company.duplicate(true) var battle: Dictionary = retried.battle battle_live_observation = Dictionary(retried.observation).duplicate(true) var events: Array = battle.get("phase_events", []) battle_live_event = Dictionary(events.back()).duplicate(true) if not events.is_empty() else {} battle_live_resume_state = "event_resolved" if not battle_live_event.is_empty() else "waiting_for_next_event" battle_live_error_code = "" battle_live_state = "loading_resume" battle_live_notice = _tr("原快照与日志验证通过", "Original snapshot and journal validated") _render_battle_live() await get_tree().process_frame _battle_live_finish_snapshot_retry() func _battle_live_finish_snapshot_retry() -> void: if battle_live_state != "loading_resume": return battle_live_state = battle_live_resume_state battle_live_notice = _tr("快照有效;手动继续后才会恢复时间线", "Snapshot valid; the timeline resumes only after manual confirmation") battle_paused = true _render_battle_live() func _battle_live_safe_exit() -> void: if battle_live_state not in ["invalid_event_data", "interrupted_background", "no_resumable_battle"]: return _invalidate_battle_playback() back_to_title() func _battle_live_fail(code: String) -> void: battle_live_error_code = code battle_live_notice = "" battle_live_state = "invalid_event_data" battle_paused = true battle_playback_generation += 1 _clear_battle_playback_timer() _render_battle_live() func _show_legacy_battle(company := {}, auto_play := true) -> void: battle_opening_mode = false battle_opening_state = "" battle_opening_handoff_receipt = {} _clear_battle_playback_timer() battle_playback_generation += 1 var playback_generation := battle_playback_generation battle_service.company_path = initial_company_service.company_path var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else {} if resolved.is_empty(): var loaded: Dictionary = battle_service.load_company() if not loaded.ok: party_notice = _battle_error_text(String(loaded.get("code", "BATTLE_LOAD_FAILED"))) show_readiness_review(guild_company) return resolved = loaded.company.duplicate(true) var battle: Dictionary = resolved.active_contract.get("battle_state", {}) if battle.is_empty(): party_notice = _tr("没有可恢复的战斗回执。", "No resumable battle receipt exists.") show_readiness_review(resolved) return if String(battle.status) == "result": show_battle_result(resolved) return battle_operation_id = String(battle.operation_id) guild_company = resolved current_screen = "battle" _build_world_base() var frame_safe := _framed_page_safe("Battle") frame_safe.add_child(_entry_chrome("BattleFrame", "initial_company_scene_frame")) var safe := _content_page_safe("Battle") var layout := VBoxContainer.new() layout.name = "BattleLayout" layout.add_theme_constant_override("separation", 4) safe.add_child(layout) var header := HBoxContainer.new() header.name = "BattleHeader" header.custom_minimum_size = Vector2(0, 42 if size.y < 500.0 else 50) layout.add_child(header) var heading := VBoxContainer.new() heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(heading) heading.add_child(_label(_tr("风镜初渡 · 自动战斗", "FIRST WINDGLASS CROSSING · AUTO BATTLE"), _scaled(19), Color("#f5ead0"))) heading.add_child(_label(_tr("目标:击破敌方队列 · 首要威胁锁定 0 号前排", "OBJECTIVE: BREAK THE ENEMY LINE · FIRST THREAT TARGETS FRONT 0"), _scaled(9), Color("#e8d39d"))) var phase_status := _label(_tr("阶段 %d/%d", "PHASE %d/%d") % [int(battle.phase_index), int(battle.total_phases)], _scaled(11), Color("#73b9e7")) phase_status.name = "BattlePhaseStatus" phase_status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER header.add_child(phase_status) var body := HBoxContainer.new() body.name = "BattleBody" body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 12) layout.add_child(body) var field := VBoxContainer.new() field.name = "Battlefield" field.size_flags_horizontal = Control.SIZE_EXPAND_FILL field.size_flags_stretch_ratio = 1.7 field.add_theme_constant_override("separation", 2) body.add_child(field) var enemy_heading := _label(_tr("敌方队列 ◁ 接触走廊", "ENEMY LINE < CONTACT CORRIDOR"), _scaled(9), Color("#f0ca75")) enemy_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT field.add_child(enemy_heading) var enemies := HBoxContainer.new() enemies.name = "BattleEnemies" enemies.custom_minimum_size = Vector2(0, 58 if size.y < 500.0 else 100) enemies.alignment = BoxContainer.ALIGNMENT_END enemies.add_theme_constant_override("separation", 7) field.add_child(enemies) for enemy_index in range(battle.enemy_ids.size()): enemies.add_child(_battle_enemy_marker(enemy_index, String(battle.enemy_ids[enemy_index]), battle)) var corridor := ColorRect.new() corridor.name = "BattleContactCorridor" corridor.custom_minimum_size = Vector2(0, 3) corridor.color = Color("#d6b76c") corridor.mouse_filter = Control.MOUSE_FILTER_IGNORE field.add_child(corridor) var actors := HBoxContainer.new() actors.name = "BattleActors" actors.size_flags_vertical = Control.SIZE_EXPAND_FILL actors.add_theme_constant_override("separation", 4) field.add_child(actors) for visual_index in range(battle.front_to_rear_ids.size() - 1, -1, -1): var recruit_id := String(battle.front_to_rear_ids[visual_index]) actors.add_child(_battle_actor(visual_index, _recruit_by_id(resolved, recruit_id), battle)) var orientation := _label(_tr("受保护后排 ◁────────▷ 暴露前排 · 0 号", "PROTECTED REAR <--------> EXPOSED FRONT · POSITION 0"), _scaled(9), Color("#9ed7aa")) orientation.name = "BattleOrientation" field.add_child(orientation) var context := VBoxContainer.new() context.name = "BattleEventContext" context.custom_minimum_size = Vector2(minf(310.0, size.x * 0.35), 0) context.add_theme_constant_override("separation", 4) body.add_child(context) _render_battle_event_context(context, resolved, battle) var footer := HBoxContainer.new() footer.name = "BattleFooter" footer.custom_minimum_size = Vector2(0, 46 if size.y < 500.0 else 52) footer.add_theme_constant_override("separation", 8) layout.add_child(footer) var pause := _company_button("BattlePause", _tr("继续观察", "Resume") if battle_paused else _tr("暂停", "Pause"), toggle_battle_pause, "company_utility", "pause") pause.custom_minimum_size = Vector2(170, footer.custom_minimum_size.y) pause.tooltip_text = _tr("继续自动事件时间线", "Resume the automatic event timeline") if battle_paused else _tr("暂停自动事件时间线", "Pause the automatic event timeline") footer.add_child(pause) var speed := _company_button("BattleSpeed", _tr("速度 %.0fx", "Speed %.0fx") % battle_speed, cycle_battle_speed, "company_secondary", "speed") speed.custom_minimum_size = Vector2(150, footer.custom_minimum_size.y) speed.tooltip_text = _tr("切换 1x / 2x 自动观察速度", "Cycle automatic observation speed between 1x and 2x") footer.add_child(speed) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(spacer) var lock := _label(_tr("风险已提交 · 战斗中不能返回 · 没有战斗指令", "RISK COMMITTED · NO BACK · NO COMBAT COMMANDS"), _scaled(9), Color("#e8d39d")) lock.name = "BattleCommittedRule" lock.vertical_alignment = VERTICAL_ALIGNMENT_CENTER footer.add_child(lock) pause.call_deferred("grab_focus") if auto_play and not battle_paused: _schedule_battle_playback(playback_generation) func _invalidate_battle_playback() -> void: battle_playback_generation += 1 battle_paused = true _clear_battle_playback_timer() func _clear_battle_playback_timer() -> void: if is_instance_valid(battle_playback_timer): battle_playback_timer.stop() battle_playback_timer.queue_free() battle_playback_timer = null func _schedule_battle_playback(playback_generation: int) -> void: _clear_battle_playback_timer() if battle_paused or current_screen != "battle" or not BATTLE_LIVE_STAGE_SECONDS.has(battle_live_state): return battle_playback_timer = Timer.new() battle_playback_timer.name = "BattlePlaybackTimer" battle_playback_timer.one_shot = true battle_playback_timer.wait_time = float(BATTLE_LIVE_STAGE_SECONDS[battle_live_state]) / maxf(1.0, battle_speed) battle_playback_timer.timeout.connect(_on_battle_playback_timeout.bind(playback_generation)) add_child(battle_playback_timer) battle_playback_timer.start() func _on_battle_playback_timeout(playback_generation: int) -> void: if playback_generation != battle_playback_generation or current_screen != "battle" or battle_paused: return if is_instance_valid(battle_playback_timer): battle_playback_timer.queue_free() battle_playback_timer = null call_deferred("advance_battle_phase") func advance_battle_phase() -> void: if current_screen != "battle" or battle_opening_mode or battle_paused: return _clear_battle_playback_timer() battle_service.company_path = initial_company_service.company_path battle_live_notice = "" match battle_live_state: "event_resolved": battle_live_event = {} battle_live_state = "waiting_for_next_event" "waiting_for_next_event": var preview: Dictionary = battle_service.preview_observation_event(battle_operation_id) if not preview.ok: _battle_live_fail(String(preview.get("code", "OBSERVATION_PREVIEW_FAILED"))) return if String(preview.get("code", "")) == "observation_outcome_locked": guild_company = preview.company.duplicate(true) battle_live_observation = Dictionary(preview.observation).duplicate(true) battle_live_state = "outcome_locked" battle_paused = true else: battle_live_event = Dictionary(preview.event).duplicate(true) battle_live_state = "event_forecast" "event_forecast": battle_live_state = "action_committed" "action_committed": battle_live_state = "event_resolving" "event_resolving": var committed: Dictionary = battle_service.commit_observation_event( battle_operation_id, String(battle_live_event.get("event_id", "")), String(battle_live_event.get("event_hash", "")) ) if not committed.ok: _battle_live_fail(String(committed.get("code", "OBSERVATION_COMMIT_FAILED"))) return guild_company = committed.company.duplicate(true) battle_live_observation = Dictionary(committed.observation).duplicate(true) battle_live_event = Dictionary(committed.event).duplicate(true) if String(committed.get("code", "")) == "observation_outcome_locked": battle_live_state = "outcome_locked" battle_paused = true else: battle_live_state = "event_resolved" _: return if battle_live_state not in ["outcome_locked", "invalid_event_data"]: battle_live_resume_state = battle_live_state _render_battle_live() if not battle_paused: _schedule_battle_playback(battle_playback_generation) func toggle_battle_pause() -> void: if battle_live_state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle", "inspect_handoff", "pause_controls_handoff"]: return battle_playback_generation += 1 _clear_battle_playback_timer() if battle_paused: battle_paused = false battle_live_state = battle_live_resume_state battle_live_notice = "" else: battle_live_resume_state = battle_live_state battle_paused = true battle_live_state = "paused" battle_live_notice = "" _render_battle_live() if not battle_paused: _schedule_battle_playback(battle_playback_generation) func cycle_battle_speed() -> void: if battle_live_state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle", "action_committed", "event_resolving", "inspect_handoff", "pause_controls_handoff"]: return battle_playback_generation += 1 _clear_battle_playback_timer() if is_equal_approx(battle_speed, 1.0): battle_speed = 2.0 elif is_equal_approx(battle_speed, 2.0): battle_speed = 4.0 else: battle_speed = 1.0 _render_battle_live() if not battle_paused: _schedule_battle_playback(battle_playback_generation) func show_battle_result(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else guild_company.duplicate(true) guild_company = resolved current_screen = "battle_result" _build_world_base() var battle: Dictionary = resolved.active_contract.get("battle_state", {}) var result: Dictionary = battle.get("result", {}) battle_result_notice = "" var view_data := _refresh_battle_result_view_data(resolved, battle, result) var view := BattleResultPageViewScript.new() view.name = "BattleResultView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "battle_result", "caller_id": "battle_live", "return_page_id": "battle_live", "return_focus_path": "BattleResultDiagnosis", "metadata": {"operation_id": String(battle.get("operation_id", ""))}, })) view.intent_emitted.connect(_on_battle_result_view_intent, CONNECT_DEFERRED) add_child(view) battle_result_view = view view.render(view_data if not view_data.is_empty() else { "ok": false, "locale": String(settings.get("locale", "zh_CN")), "notice": battle_result_notice, }) func _on_battle_result_view_intent(intent_id: String, _payload: Dictionary) -> void: match intent_id: "diagnosis": toggle_battle_result_diagnosis() "continue": continue_from_battle_result() func _refresh_battle_result_view_data(company: Dictionary, battle: Dictionary, result: Dictionary) -> Dictionary: var snapshot: Dictionary = battle_result_page_controller.snapshot( company, battle, result, battle_result_diagnosis_open, battle_result_notice, String(settings.get("locale", "zh_CN")) ) if not bool(snapshot.get("ok", false)): battle_result_view_data = {} battle_result_notice = _battle_error_text(String(snapshot.get("code", "RESULT_REQUIRED"))) return {} battle_result_view_data = snapshot return snapshot func toggle_battle_result_diagnosis() -> void: battle_result_diagnosis_open = not battle_result_diagnosis_open show_battle_result(guild_company) func continue_from_battle_result() -> void: var view_data: Dictionary = battle_result_view_data if view_data.is_empty(): var battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {}) view_data = _refresh_battle_result_view_data(guild_company, battle, Dictionary(battle.get("result", {}))) if view_data.is_empty() or not bool(Dictionary(view_data.get("continuation", {})).get("eligible", false)): return match String(Dictionary(view_data.get("continuation", {})).get("target", "")): "battle_knockout_review": show_battle_knockout_review(guild_company) "battle_reward_choice": show_battle_reward_choice(guild_company) "battle_recovery": show_battle_recovery(guild_company) func show_battle_knockout_review(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else guild_company.duplicate(true) guild_company = resolved current_screen = "battle_knockout_review" _build_world_base() var battle: Dictionary = resolved.active_contract.get("battle_state", {}) battle_knockout_notice = "" var view_data := _refresh_battle_knockout_view_data(resolved, battle) if not view_data.is_empty(): view_data["presentation"] = _knockout_review_presentation(view_data) var view := KnockoutReviewPageViewScript.new() view.name = "BattleKnockoutView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "battle_knockout_review", "caller_id": "battle_result", "return_page_id": "battle_result", "return_focus_path": "BattleKnockoutBack", "metadata": {"operation_id": String(battle.get("operation_id", ""))}, })) view.intent_emitted.connect(_on_battle_knockout_view_intent, CONNECT_DEFERRED) add_child(view) battle_knockout_view = view view.render(view_data if not view_data.is_empty() else { "ok": false, "locale": String(settings.get("locale", "zh_CN")), "notice": battle_knockout_notice, }) func _on_battle_knockout_view_intent(intent_id: String, _payload: Dictionary) -> void: match intent_id: "back": show_battle_result(guild_company) "acknowledge": acknowledge_battle_knockout(guild_company) func _knockout_review_presentation(view_data: Dictionary) -> Dictionary: var names := {} var knockouts: Dictionary = Dictionary(view_data.get("knockouts", {})) for identity in knockouts.get("identities", []): if not identity is Dictionary: continue var recruit: Dictionary = Dictionary(identity.get("recruit", {})) var equipped_id := String(recruit.get("equipped_item_id", "")) var starting_id := String(recruit.get("starting_item_id", "")) if not equipped_id.is_empty() and equipped_id != starting_id: names[equipped_id] = _content_name("equipment", equipped_id) return {"equipment_names": names} func _refresh_battle_knockout_view_data(company: Dictionary, battle: Dictionary) -> Dictionary: var snapshot: Dictionary = knockout_review_page_controller.snapshot( company, battle, Dictionary(battle.get("result", {})), battle_knockout_notice, String(settings.get("locale", "zh_CN")) ) if not bool(snapshot.get("ok", false)): battle_knockout_view_data = {} battle_knockout_notice = _battle_error_text(String(snapshot.get("code", "KNOCKOUT_RESULT_REQUIRED"))) return {} battle_knockout_view_data = snapshot return snapshot func acknowledge_battle_knockout(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else guild_company.duplicate(true) var view_data: Dictionary = battle_knockout_view_data if view_data.is_empty(): var battle: Dictionary = resolved.get("active_contract", {}).get("battle_state", {}) view_data = _refresh_battle_knockout_view_data(resolved, battle) if view_data.is_empty() or not bool(Dictionary(view_data.get("acknowledgement", {})).get("eligible", false)): return match String(Dictionary(view_data.get("acknowledgement", {})).get("target", "")): "battle_reward_choice": show_battle_reward_choice(resolved) "battle_recovery": show_battle_recovery(resolved) func show_battle_reward_choice(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else guild_company.duplicate(true) var battle: Dictionary = resolved.get("active_contract", {}).get("battle_state", {}) var draft: Dictionary = Dictionary(battle.get("reward_draft", {})) var view_data := _refresh_battle_reward_view_data(resolved, battle) if view_data.is_empty(): if draft.is_empty() or String(draft.get("status", "open")) != "open": show_battle_recovery(resolved) return _build_world_base() var invalid_view := RewardChoicePageViewScript.new() invalid_view.name = "BattleRewardView" invalid_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) invalid_view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) invalid_view.mount(PageContextScript.new({"page_id": "battle_reward_choice", "caller_id": "battle_result", "return_page_id": "battle_result"})) add_child(invalid_view) battle_reward_view = invalid_view invalid_view.render({ "ok": false, "locale": String(settings.get("locale", "zh_CN")), "notice": battle_reward_notice, }) return guild_company = resolved current_screen = "battle_reward_choice" _build_world_base() view_data["presentation"] = _reward_choice_presentation(view_data) var view := RewardChoicePageViewScript.new() view.name = "BattleRewardView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "battle_reward_choice", "caller_id": "battle_result", "return_page_id": "battle_result", "return_focus_path": "BattleRewardBack", "metadata": {"operation_id": String(battle.get("operation_id", ""))}, })) view.intent_emitted.connect(_on_battle_reward_view_intent, CONNECT_DEFERRED) add_child(view) battle_reward_view = view view.render(view_data) func _on_battle_reward_view_intent(intent_id: String, payload: Dictionary) -> void: match intent_id: "select": select_battle_reward(String(payload.get("reward_id", ""))) "back": show_battle_result(guild_company) "skip": decline_battle_reward() "claim": commit_battle_reward() func _reward_choice_presentation(view_data: Dictionary) -> Dictionary: var names := {} var reward_data: Dictionary = Dictionary(view_data.get("reward", {})) for raw_option in reward_data.get("options", []): if raw_option is Dictionary and String(raw_option.get("kind", "")) == "equipment": var catalog_id := String(raw_option.get("catalog_id", "")) if not catalog_id.is_empty(): names[catalog_id] = _content_name("equipment", catalog_id) return {"equipment_names": names} func _refresh_battle_reward_view_data(company: Dictionary, battle: Dictionary, selected_id := battle_reward_selected_id) -> Dictionary: var snapshot: Dictionary = reward_choice_page_controller.snapshot( company, battle, Dictionary(battle.get("reward_draft", {})), String(selected_id), battle_reward_notice, String(settings.get("locale", "zh_CN")) ) if not bool(snapshot.get("ok", false)): battle_reward_view_data = {} battle_reward_notice = _battle_error_text(String(snapshot.get("code", "REWARD_CHOICE_DRAFT_REQUIRED"))) return {} battle_reward_view_data = snapshot return snapshot func select_battle_reward(reward_id: String) -> void: var battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {}) var draft: Dictionary = battle.get("reward_draft", {}) var candidate: Dictionary = reward_choice_page_controller.snapshot( guild_company, battle, draft, reward_id, "", String(settings.get("locale", "zh_CN")) ) if not bool(candidate.get("ok", false)): return battle_reward_selected_id = String(Dictionary(candidate.get("reward", {})).get("selected_reward_id", "")) battle_reward_notice = "" show_battle_reward_choice(guild_company) func commit_battle_reward() -> void: if battle_reward_selected_id.is_empty(): battle_reward_notice = _tr("请先选择一个奖励。", "Select a reward first.") show_battle_reward_choice(guild_company) return battle_service.company_path = initial_company_service.company_path var loaded: Dictionary = battle_service.load_company() if not loaded.ok: battle_reward_notice = _battle_error_text(String(loaded.get("code", "REWARD_LOAD_FAILED"))) show_battle_reward_choice(guild_company) return var battle: Dictionary = loaded.company.active_contract.get("battle_state", {}) var operation_id := ("%s|reward|%s" % [String(battle.get("operation_id", "")), battle_reward_selected_id]).sha256_text().substr(0, 32) var claimed: Dictionary = battle_service.claim_reward(operation_id, battle_reward_selected_id, String(battle.get("operation_id", ""))) if not claimed.ok: battle_reward_notice = _battle_error_text(String(claimed.get("code", "REWARD_FAILED"))) show_battle_reward_choice(loaded.company) return guild_company = claimed.company.duplicate(true) battle_reward_notice = "" battle_reward_selected_id = "" show_battle_recovery(guild_company) func decline_battle_reward() -> void: battle_service.company_path = initial_company_service.company_path var loaded: Dictionary = battle_service.load_company() if not loaded.ok: battle_reward_notice = _battle_error_text(String(loaded.get("code", "REWARD_LOAD_FAILED"))) show_battle_reward_choice(guild_company) return var battle: Dictionary = loaded.company.active_contract.get("battle_state", {}) var operation_id := ("%s|reward|decline" % String(battle.get("operation_id", ""))).sha256_text().substr(0, 32) var declined: Dictionary = battle_service.decline_reward(operation_id, String(battle.get("operation_id", ""))) if not declined.ok: battle_reward_notice = _battle_error_text(String(declined.get("code", "REWARD_FAILED"))) show_battle_reward_choice(loaded.company) return guild_company = declined.company.duplicate(true) battle_reward_selected_id = "" battle_reward_notice = "" show_battle_recovery(guild_company) func _reward_option_from_draft(draft: Dictionary, reward_id: String) -> Dictionary: if reward_id.is_empty(): return {} for raw_option in draft.get("options", []): var option: Dictionary = raw_option if String(option.get("reward_id", "")) == reward_id: return option.duplicate(true) return {} func _battle_reward_option_title(option: Dictionary, selected: bool = false) -> String: var marker := "◆ " if selected else "◇ " var kind := String(option.get("kind", "")) if kind == "equipment": return "%s%s" % [marker, _tr("免费装备 · %s", "FREE EQUIPMENT · %s") % _content_name("equipment", String(option.get("catalog_id", "")))] return "%s%s" % [marker, _tr("金币储备 · +%d", "COIN RESERVE · +%d") % int(option.get("amount", 0))] func _battle_reward_option_detail(option: Dictionary) -> String: return _tr( "改变:%s\n目标:%s\n冲突:%s\n跳过:%s", "Changes: %s\nTarget: %s\nConflict: %s\nSkip: %s" ) % [String(option.get("next_decision", "")), String(option.get("eligible_target", "")), String(option.get("conflict", "")), String(option.get("skip_outcome", ""))] func show_battle_recovery(company := {}) -> void: var resolved: Dictionary = Dictionary(company).duplicate(true) if company is Dictionary else guild_company.duplicate(true) guild_company = resolved current_screen = "battle_recovery" _build_world_base() var battle: Dictionary = resolved.active_contract.get("battle_state", {}) var view_data := _refresh_battle_recovery_view_data(resolved, battle) var view := RecoveryPageViewScript.new() view.name = "BattleRecoveryView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "battle_recovery", "caller_id": "battle_knockout_review", "return_page_id": "battle_knockout_review", "return_focus_path": "BattleRecoveryBack", "metadata": {"operation_id": String(battle.get("operation_id", ""))}, })) view.intent_emitted.connect(_on_battle_recovery_view_intent, CONNECT_DEFERRED) add_child(view) battle_recovery_view = view view.render(view_data if not view_data.is_empty() else { "ok": false, "locale": String(settings.get("locale", "zh_CN")), "notice": battle_notice, }) func _on_battle_recovery_view_intent(intent_id: String, _payload: Dictionary) -> void: match intent_id: "back": show_battle_knockout_review(guild_company) "commit": commit_battle_recovery() func _refresh_battle_recovery_view_data(company: Dictionary, battle: Dictionary) -> Dictionary: var contract: Dictionary = Dictionary(company.get("active_contract", {})) var snapshot: Dictionary = recovery_page_controller.snapshot( company, battle, Dictionary(battle.get("result", {})), Dictionary(battle.get("reward_draft", {})), Array(contract.get("recovery_receipts", [])), battle_notice, String(settings.get("locale", "zh_CN")) ) if not bool(snapshot.get("ok", false)): battle_recovery_view_data = {} battle_notice = _battle_error_text(String(snapshot.get("code", "RECOVERY_RESULT_REQUIRED"))) return {} battle_recovery_view_data = snapshot return snapshot func commit_battle_recovery() -> void: var current_battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {}) var view_data: Dictionary = battle_recovery_view_data if view_data.is_empty(): view_data = _refresh_battle_recovery_view_data(guild_company, current_battle) if view_data.is_empty() or not bool(Dictionary(view_data.get("commands", {})).get("commit_enabled", false)): return battle_service.company_path = initial_company_service.company_path var loaded: Dictionary = battle_service.load_company() if not loaded.ok: battle_notice = _battle_error_text(String(loaded.get("code", "RECOVERY_LOAD_FAILED"))) show_battle_recovery(guild_company) return var battle: Dictionary = loaded.company.active_contract.get("battle_state", {}) var operation_id := ("%s|recovery|%s" % [String(battle.get("operation_id", "")), String(loaded.company.company_id)]).sha256_text().substr(0, 32) var recovered: Dictionary = battle_service.recover_next_stage(operation_id) if not recovered.ok: battle_notice = _battle_error_text(String(recovered.get("code", "RECOVERY_FAILED"))) show_battle_recovery(loaded.company) return battle_notice = "" guild_company = recovered.company.duplicate(true) show_market(guild_company) func set_market_category(value: String) -> void: if value not in MarketPageControllerScript.CATEGORIES: return market_category = value market_selected_kind = "" market_selected_offer_id = "" market_notice = "" show_market(guild_company) func select_market_offer(kind: String, offer_id: String) -> void: var selection: Dictionary = market_page_controller.select_offer(guild_company, kind, offer_id) if not selection.get("ok", false): market_notice = _tr("该货单条目已不可用。", "That ledger entry is no longer available.") else: market_selected_kind = String(selection.get("kind", "")) market_selected_offer_id = String(selection.get("offer_id", "")) market_notice = "" show_market(guild_company) func open_recruit_review() -> void: var selection: Dictionary = market_page_controller.select_offer(guild_company, market_selected_kind, market_selected_offer_id) if not selection.get("ok", false) or String(selection.get("review", {}).get("action", "")) != "recruit_review": market_notice = _tr("请先选择一名招募候选。", "Select a Recruit offer first.") show_market(guild_company) return show_recruit_review(market_selected_offer_id) func open_equipment_review() -> void: var selection: Dictionary = market_page_controller.select_offer(guild_company, market_selected_kind, market_selected_offer_id) if not selection.get("ok", false) or String(selection.get("review", {}).get("action", "")) != "equipment_review": market_notice = _tr("请先选择一件装备。", "Select an equipment offer first.") show_market(guild_company) return show_equipment_review(market_selected_offer_id) func show_equipment_review(offer_id: String) -> void: var loaded: Dictionary = initial_company_service.load_company() if not loaded.ok or loaded.company.active_contract.is_empty(): market_notice = _tr("无法读取稳定装备货单。", "The stable Equipment ledger could not be loaded.") show_market() return var offer := _market_offer_by_id(loaded.company.active_contract, "equipment", offer_id) if offer.is_empty(): market_notice = _tr("该装备候选已不可用。", "That equipment offer is no longer available.") show_market(loaded.company) return guild_company = loaded.company.duplicate(true) market_category = "equipment" market_selected_kind = "equipment" market_selected_offer_id = offer_id current_screen = "equipment_review" _build_world_base(true) var frame_safe := _framed_page_safe("EquipmentReview") frame_safe.add_child(_entry_chrome("EquipmentReviewFrame", "content_scene_frame")) var safe := _content_page_safe("EquipmentReview") var layout := VBoxContainer.new() layout.name = "EquipmentReviewLayout" layout.add_theme_constant_override("separation", 5) safe.add_child(layout) var header := HBoxContainer.new() header.custom_minimum_size = Vector2(0, 44 if size.y < 500.0 else 50) layout.add_child(header) var heading := VBoxContainer.new() heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(heading) heading.add_child(_label(_tr("装备详情", "EQUIPMENT REVIEW"), _scaled(20), Color("#f5ead0"))) heading.add_child(_label(_market_offer_title("equipment", offer), _scaled(11), Color("#e8d39d"))) var price := _label(_market_offer_price("equipment", offer), _scaled(15), Color("#f0ca75")) price.vertical_alignment = VERTICAL_ALIGNMENT_CENTER header.add_child(price) var body := HBoxContainer.new() body.name = "EquipmentReviewBody" body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 16) layout.add_child(body) var detail := VBoxContainer.new() detail.name = "EquipmentReviewDetail" detail.size_flags_horizontal = Control.SIZE_EXPAND_FILL detail.size_flags_stretch_ratio = 1.05 detail.add_theme_constant_override("separation", 5) body.add_child(detail) detail.add_child(_label(_market_offer_title("equipment", offer), _scaled(17), Color("#f0ca75"))) var effect := _label(_market_context_detail("equipment", offer), _scaled(11), Color("#f5ead0")) effect.name = "EquipmentReviewEffect" effect.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART effect.size_flags_vertical = Control.SIZE_EXPAND_FILL detail.add_child(effect) var inventory_note := _label(_tr("购买后装备到一名 Recruit;原装备返回 Company inventory。", "Purchase equips one Recruit; the previous item returns to Company inventory."), _scaled(10), Color("#9ed7aa")) inventory_note.name = "EquipmentReviewInventoryRule" inventory_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail.add_child(inventory_note) var targets := VBoxContainer.new() targets.name = "EquipmentReviewTargets" targets.size_flags_horizontal = Control.SIZE_EXPAND_FILL targets.size_flags_stretch_ratio = 1.0 targets.add_theme_constant_override("separation", 4) body.add_child(targets) targets.add_child(_label(_tr("选择装备成员", "CHOOSE WEARER"), _scaled(10), Color("#e8d39d"))) var target_grid := GridContainer.new() target_grid.name = "EquipmentReviewTargetGrid" target_grid.columns = 2 target_grid.size_flags_vertical = Control.SIZE_EXPAND_FILL target_grid.add_theme_constant_override("h_separation", 5) target_grid.add_theme_constant_override("v_separation", 5) targets.add_child(target_grid) for recruit in guild_company.owned_recruits: var recruit_id := String(recruit.recruit_id) var equip := _company_button("EquipmentEquipRecruit%s" % recruit_id, _tr("装备 %s", "Equip %s") % _recruit_name(recruit), purchase_selected_equipment.bind(recruit_id), "content_secondary", "confirm") equip.custom_minimum_size = Vector2(180, 50 if size.y < 500.0 else 54) var affordable := int(guild_company.active_contract.coin) >= int(offer.list_price) _set_entry_button_disabled(equip, not affordable) equip.tooltip_text = _tr("装备到 %s;当前:%s", "Equip on %s; current: %s") % [_recruit_name(recruit), _recruit_item_name(recruit)] if affordable else _tr("金币不足:需要 %d,当前 %d;未改变状态", "Insufficient Coin: need %d, have %d; state is unchanged") % [int(offer.list_price), int(guild_company.active_contract.coin)] target_grid.add_child(equip) var footer := HBoxContainer.new() footer.name = "EquipmentReviewFooter" footer.custom_minimum_size = Vector2(0, 46 if size.y < 500.0 else 50) footer.add_theme_constant_override("separation", 8) layout.add_child(footer) var cancel := _company_button("EquipmentReviewCancel", _tr("返回市场", "Back to Market"), cancel_equipment_review, "company_utility", "back") footer.add_child(cancel) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(spacer) var state := _label(_tr("选择成员后提交一次", "Choose a Recruit to commit once"), _scaled(10), Color("#e8d39d")) state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER footer.add_child(state) cancel.call_deferred("grab_focus") func cancel_equipment_review() -> void: market_notice = "" show_market(guild_company) func purchase_selected_equipment(recruit_id: String) -> void: if market_selected_kind != "equipment" or market_selected_offer_id.is_empty(): market_notice = _tr("没有可提交的装备候选。", "No equipment offer is ready to commit.") show_market(guild_company) return var offer := _market_offer_by_id(guild_company.active_contract, "equipment", market_selected_offer_id) if offer.is_empty(): market_notice = _tr("该装备候选已不可用。", "That equipment offer is unavailable.") show_market(guild_company) return guild_contract_service.company_path = initial_company_service.company_path var operation_id := ("%s|equip|%s|%s" % [String(guild_company.company_id), market_selected_offer_id, recruit_id]).sha256_text().substr(0, 32) var result: Dictionary = guild_contract_service.purchase_equipment(operation_id, market_selected_offer_id, recruit_id, int(offer.list_price)) if not result.ok: market_notice = _equipment_error_text(String(result.get("code", "EQUIPMENT_FAILED"))) show_equipment_review(market_selected_offer_id) return guild_company = result.company.duplicate(true) market_notice = _tr("已装备 %s · 金币 %d → %d", "Equipped %s · Coin %d -> %d") % [_market_offer_title("equipment", offer), int(result.receipt.coin_before), int(result.receipt.coin_after)] market_selected_kind = "" market_selected_offer_id = "" show_market(guild_company) func show_recruit_review(offer_id: String) -> void: var loaded := initial_company_service.load_company() if not loaded.ok or loaded.company.active_contract.is_empty(): market_notice = _tr("无法读取稳定货单。", "The stable Market ledger could not be loaded.") show_market() return var review_data: Dictionary = recruit_review_page_controller.snapshot( loaded.company, offer_id, String(settings.get("locale", "zh_CN")), market_content, ) if not review_data.get("ok", false): market_notice = _tr("该招募候选已不可用。", "That Recruit offer is no longer available.") show_market(loaded.company) return recruit_review_view_data = review_data.duplicate(true) guild_company = loaded.company.duplicate(true) market_category = "recruit" market_selected_kind = "recruit" market_selected_offer_id = offer_id current_screen = "recruit_review" for child in get_children(): child.free() var view := RecruitReviewPageViewScript.new() view.name = "RecruitReviewView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({ "safe_area": int(settings.get("safe_area", 24)), "text_scale": float(settings.get("text_scale", 1.0)), "locale": String(settings.get("locale", "zh_CN")), }) view.mount(PageContextScript.new({ "page_id": "recruit_review", "caller_id": "market", "return_page_id": "market", "return_focus_path": "MarketOffer%s" % offer_id, "metadata": { "company_id": String(guild_company.get("company_id", "")), "source_offer_id": offer_id, }, })) view.intent_emitted.connect(_on_recruit_review_view_intent, CONNECT_DEFERRED) add_child(view) recruit_review_view = view view.render(review_data) func _on_recruit_review_view_intent(intent_id: String, _payload: Dictionary) -> void: match intent_id: "cancel": cancel_recruit_review() "hire": hire_selected_recruit() "settings": _recruit_review_open_settings() func _recruit_review_open_settings() -> void: settings_caller = "recruit_review" show_settings() func cancel_recruit_review() -> void: market_notice = "" show_market(guild_company) func hire_selected_recruit() -> void: if market_selected_kind != "recruit" or market_selected_offer_id.is_empty(): market_notice = _tr("没有可提交的招募候选。", "No Recruit offer is ready to commit.") show_market(guild_company) return var review_data: Dictionary = recruit_review_page_controller.snapshot( guild_company, market_selected_offer_id, String(settings.get("locale", "zh_CN")), market_content, ) if not review_data.get("ok", false): market_notice = _tr("该招募候选已不可用。", "That Recruit offer is unavailable.") show_market(guild_company) return var hire_state: Dictionary = Dictionary(review_data.get("hire", {})) if not hire_state.get("eligible", false): var reason := String(hire_state.get("reason", "")) var code := "OPENING_HIRES_COMPLETE" if reason == "opening_hires_complete" else "HIRE_INSUFFICIENT_COIN" if reason == "insufficient_coin" else "HIRE_OFFER_UNAVAILABLE" market_notice = _hire_error_text(code) show_recruit_review(market_selected_offer_id) return var offer: Dictionary = Dictionary(review_data.get("offer", {})) guild_contract_service.company_path = initial_company_service.company_path var operation_id := ("%s|hire-recruit|%s" % [String(guild_company.company_id), market_selected_offer_id]).sha256_text().substr(0, 32) var result: Dictionary = guild_contract_service.hire_recruit(operation_id, market_selected_offer_id, int(offer.list_price)) if not result.ok: market_notice = _hire_error_text(String(result.get("code", "HIRE_FAILED"))) show_recruit_review(market_selected_offer_id) return guild_company = result.company.duplicate(true) var receipt: Dictionary = result.receipt market_category = "recruit" market_selected_kind = "" market_selected_offer_id = "" market_notice = _tr("已招募 %s · 金币 %d → %d · 远征团 %d → %d", "Recruited %s · Coin %d → %d · Company %d → %d") % [_market_recruit_name(offer), int(receipt.coin_before), int(receipt.coin_after), int(receipt.company_before), int(receipt.company_after)] show_market(guild_company) func _market_category_button(category: String) -> Button: var selected: bool = category == market_category var equipment_count := 4 if not guild_company.is_empty(): equipment_count = guild_company.active_contract.get("available_equipment_offer_ids", []).size() var title: String = { "recruit": _tr("招募 3", "Recruit 3"), "equipment": _tr("装备 %d", "Equipment %d") % equipment_count, "battle": _tr("战斗 3", "Battle 3"), }[category] var button := _entry_button_base("MarketCategory%s" % category.capitalize(), set_market_category.bind(category), "content_primary" if selected else "content_secondary") button.custom_minimum_size = Vector2(0, 44) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.add_child(_button_label("ButtonLabel", String(title), _scaled(11), Color("#202a31") if selected else Color("#f5ead0"))) button.set_meta("selected", selected) return button func _market_offer_row(kind: String, offer: Dictionary, index: int) -> Button: var offer_id: String = _market_offer_id(kind, offer) var selected: bool = kind == market_selected_kind and offer_id == market_selected_offer_id var button := _entry_button_base("MarketOffer%s" % offer_id, select_market_offer.bind(kind, offer_id), "content_primary" if selected else "content_secondary") button.set_meta("offer_id", offer_id) button.set_meta("offer_kind", kind) button.set_meta("selected", selected) var short_market := size.y < 380.0 button.custom_minimum_size = Vector2(0, 56 if kind == "recruit" and short_market else 62 if kind == "recruit" else 45 if short_market else 49) button.size_flags_vertical = Control.SIZE_EXPAND_FILL var left_inset := 62.0 if kind == "recruit" else 14.0 if kind == "recruit": var portrait := TextureRect.new() portrait.texture = InitialCompanyArt.texture_for(offer) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.position = Vector2(7, 3) portrait.size = Vector2(46 if short_market else 48, 50 if short_market else 56) portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(portrait) var title := _label(_market_offer_title(kind, offer), _scaled(13), Color("#202a31") if selected else Color("#f5ead0")) title.position = Vector2(left_inset, 5) title.size = Vector2(maxf(80.0, 240.0 - left_inset), 22) title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS title.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(title) var detail := _label(_market_offer_detail(kind, offer), _scaled(9), Color("#394a52") if selected else Color("#d0d7d5")) detail.position = Vector2(left_inset, 25) detail.size = Vector2(maxf(80.0, 315.0 - left_inset), 30 if kind == "recruit" else 19) detail.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS detail.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(detail) var price := _label(_market_offer_price(kind, offer), _scaled(11), Color("#6f562d") if selected else Color("#f0ca75")) price.anchor_left = 1.0 price.anchor_right = 1.0 price.offset_left = -88 price.offset_right = -10 price.offset_top = 5 price.offset_bottom = 27 price.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT price.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(price) return button func _render_market_context(context: VBoxContainer, contract: Dictionary) -> void: context.add_child(_label(_tr("检查", "INSPECT"), _scaled(10), Color("#e8d39d"))) if market_selected_offer_id.is_empty(): var empty := _label(_tr("选择任意货单条目查看完整信息。\n没有候选被自动选择或标记为推荐。", "Select any ledger entry to inspect it.\nNo offer is auto-selected or marked recommended."), _scaled(13), Color("#f5ead0")) empty.name = "MarketNoSelection" empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART empty.size_flags_vertical = Control.SIZE_EXPAND_FILL context.add_child(empty) if not market_notice.is_empty(): var receipt_notice := _label(market_notice, _scaled(11), Color("#9ed7aa")) receipt_notice.name = "MarketReceiptNotice" receipt_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(receipt_notice) return var offer := _market_offer_by_id(contract, market_selected_kind, market_selected_offer_id) if offer.is_empty(): context.add_child(_label(_tr("该条目已不可用。", "That entry is unavailable."), _scaled(13), Color("#ffd4d4"))) return context.add_child(_label(_market_offer_title(market_selected_kind, offer), _scaled(17), Color("#f5ead0"))) var detail := _label(_market_context_detail(market_selected_kind, offer), _scaled(11), Color("#aebbc2")) detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail.size_flags_vertical = Control.SIZE_EXPAND_FILL context.add_child(detail) context.add_child(_label(_market_offer_price(market_selected_kind, offer), _scaled(14), Color("#f0ca75"))) if market_selected_kind == "recruit": var inspect := _company_button("MarketReviewRecruit", _tr("查看招募详情", "Review Recruit"), open_recruit_review, "content_primary", "inspect") inspect.custom_minimum_size = Vector2(0, 50) context.add_child(inspect) elif market_selected_kind == "equipment": var equipment_review := _company_button("MarketReviewEquipment", _tr("查看装备并选择成员", "Review & Choose Wearer"), open_equipment_review, "content_primary", "inspect") equipment_review.custom_minimum_size = Vector2(0, 50) equipment_review.tooltip_text = _tr("查看效果、取舍与四名成员的当前装备", "Review effect, tradeoff and the current item on all four Recruits") context.add_child(equipment_review) if not market_notice.is_empty(): var notice := _label(market_notice, _scaled(10), Color("#f0ca75")) notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(notice) func _market_categories() -> Array[String]: return ["recruit", "equipment", "battle"] func _market_collection_field(kind: String) -> String: return {"recruit": "recruit_offers", "equipment": "equipment_offers", "battle": "battle_offers"}.get(kind, "recruit_offers") func _market_offer_id(kind: String, offer: Dictionary) -> String: return String(offer.get("source_offer_id" if kind == "recruit" else "offer_id", "")) func _market_offer_by_id(contract: Dictionary, kind: String, offer_id: String) -> Dictionary: return market_page_controller.offer_by_id(contract, kind, offer_id) func _market_offer_title(kind: String, offer: Dictionary) -> String: return market_page_controller.offer_title(kind, offer, String(settings.get("locale", "zh_CN")), market_content) func _market_offer_detail(kind: String, offer: Dictionary) -> String: return market_page_controller.offer_detail(kind, offer, guild_company, String(settings.get("locale", "zh_CN")), market_content) func _market_offer_price(kind: String, offer: Dictionary) -> String: return market_page_controller.offer_price(kind, offer, guild_company, String(settings.get("locale", "zh_CN"))) func _market_context_detail(kind: String, offer: Dictionary) -> String: return market_page_controller.context_detail(kind, offer, String(settings.get("locale", "zh_CN")), market_content) func _market_recruit_name(offer: Dictionary) -> String: return market_page_controller.recruit_name(offer) func _content_name(family: String, row_id: String) -> String: var locale := String(settings.get("locale", "zh_CN")) return RuntimeContentRegistry.localized_name(market_content, row_id, locale) func _profession_job(offer: Dictionary) -> String: var row: Dictionary = RuntimeContentRegistry.rows_by_id(market_content, "professions").get(String(offer.profession_id), {}) var fallback := String(row.get("role", "")) return MarketLocalization.profession_job(String(offer.profession_id), fallback, String(settings.get("locale", "zh_CN"))) func _trait_value(offer: Dictionary, field: String) -> String: var row: Dictionary = RuntimeContentRegistry.rows_by_id(market_content, "traits").get(String(offer.trait_id), {}) var fallback := String(row.get(field, "")) return MarketLocalization.trait_value(String(offer.trait_id), field, fallback, String(settings.get("locale", "zh_CN"))) func _equipment_effect(offer: Dictionary) -> String: return _equipment_effect_by_id(String(offer.starting_equipment_id)) func _equipment_effect_by_id(item_id: String) -> String: var row: Dictionary = RuntimeContentRegistry.rows_by_id(market_content, "equipment").get(item_id, {}) var fallback := String(row.get("effect", "")) return MarketLocalization.equipment_value(item_id, "effect", fallback, String(settings.get("locale", "zh_CN"))) func _equipment_error_text(code: String) -> String: match code: "EQUIPMENT_MARKET_REQUIRED": return _tr("装备只可在市场阶段提交。", "Equipment can only be committed during Market.") "EQUIPMENT_OFFER_UNAVAILABLE": return _tr("该装备已不可用;未改变状态。", "That equipment offer is unavailable; state is unchanged.") "EQUIPMENT_PRICE_MISMATCH": return _tr("价格已变化;请重新检查货单。", "The price changed; review the ledger again.") "EQUIPMENT_INSUFFICIENT_COIN": return _tr("金币不足;未购买装备。", "Insufficient Coin; equipment was not purchased.") "EQUIPMENT_RECRUIT_UNAVAILABLE": return _tr("该 Recruit 已不可用;未购买装备。", "That Recruit is unavailable; equipment was not purchased.") "EQUIPMENT_OPERATION_CONFLICT": return _tr("重复操作的目标不同;原装备回执保持不变。", "The repeated operation targets a different intent; the original receipt is unchanged.") "EQUIPMENT_OFFER_ALREADY_APPLIED_CONFLICT": return _tr("该装备已提交给另一名成员;请选择仍可用的货单。", "That equipment was already committed to another Recruit; choose an available offer.") return _tr("装备交易未提交;Company 没有改变。", "Equipment transaction was not committed; Company is unchanged.") + " [%s]" % code func _attribute_summary(offer: Dictionary) -> String: var attributes: Dictionary = offer.get("attribute_rolls", {}) return "VIG %s · FOR %s · FIN %s · RES %s · FOC %s" % [String(attributes.get("vigor", "-")), String(attributes.get("force", "-")), String(attributes.get("finesse", "-")), String(attributes.get("resolve", "-")), String(attributes.get("focus", "-"))] func _add_review_detail(parent: VBoxContainer, node_name: String, heading: String, value: String) -> void: var line := _label("%s\n%s" % [heading, value], _scaled(10), Color("#f5ead0")) line.name = node_name line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART line.size_flags_horizontal = Control.SIZE_EXPAND_FILL parent.add_child(line) func back_to_guild() -> void: var loaded := initial_company_service.load_company() if loaded.ok: show_guild_handoff(loaded.company) else: title_notice = _tr("无法读取远征团。", "The Company could not be loaded.") + " [%s]" % String(loaded.code) show_title() func _framed_page_safe(prefix: String) -> MarginContainer: var safe := MarginContainer.new() safe.name = "%sFrameSafeArea" % prefix safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) safe.mouse_filter = Control.MOUSE_FILTER_IGNORE var margin := maxi(12, int(settings.get("safe_area", 24))) var vertical_margin := 12 if size.y < 500.0 else margin safe.add_theme_constant_override("margin_left", margin) safe.add_theme_constant_override("margin_right", margin) safe.add_theme_constant_override("margin_top", vertical_margin) safe.add_theme_constant_override("margin_bottom", vertical_margin) add_child(safe) return safe func _content_page_safe(prefix: String) -> MarginContainer: var safe := MarginContainer.new() safe.name = "%sSafeArea" % prefix safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) var margin := maxi(12, int(settings.get("safe_area", 24))) var vertical_margin := 12 if size.y < 500.0 else margin safe.add_theme_constant_override("margin_left", margin + 12) safe.add_theme_constant_override("margin_right", margin + 12) safe.add_theme_constant_override("margin_top", vertical_margin + 8) safe.add_theme_constant_override("margin_bottom", vertical_margin + 8) add_child(safe) return safe func _difficulty_name(value: String) -> String: match value: "wayfinder": return _tr("引路", "Wayfinder") "stormbound": return _tr("风暴", "Stormbound") "iron_oath": return _tr("铁誓", "Iron Oath") return _tr("标准", "Standard") func _difficulty_copy(value: String) -> String: match value: "wayfinder": return _tr("较宽容的敌方参数;所有系统、进度与记录仍然可用。", "More forgiving enemy parameters; all systems, progression and Records remain available.") "stormbound": return _tr("更高压力与回报;目标规则和远征团所有权不变。", "Higher pressure and rewards; target rules and Company ownership remain unchanged.") "iron_oath": return _tr("最高压力;只改变敌人与回报参数,不隐藏规则。", "Highest pressure; only enemy and reward parameters change, never hidden rules.") return _tr("完整的标准平衡;不需要额外永久战力。", "The complete standard balance; no extra permanent power is required.") func _contract_error_text(code: String) -> String: match code: "CONTRACT_ALREADY_ACTIVE": return _tr("已有进行中的委托。", "A Contract is already active.") "CONTRACT_LOCKED": return _tr("该委托尚未解锁。", "That Contract is still locked.") "CONTRACT_DIFFICULTY_INVALID": return _tr("难度无效,请重新选择。", "The difficulty is invalid; choose again.") return _tr("无法建立委托;远征团与资源未改变。", "The Contract could not be created; Company and resources are unchanged.") + " [%s]" % code func _contract_choice_guard_text(reason: String) -> String: match reason: "COMPANY_REQUIRED", "company_required": return _tr("无法读取远征团;未创建委托。", "The Company could not be read; no Contract was created.") "COMPANY_INVALID", "company_invalid": return _tr("远征团数据无效;未创建委托。", "The Company data is invalid; no Contract was created.") "CONTRACT_ALREADY_ACTIVE", "contract_already_active": return _tr("已有进行中的委托;请进入市场。", "A Contract is already active; enter its Market.") "invalid_difficulty": return _tr("难度无效,请重新选择。", "The difficulty is invalid; choose again.") return _tr("委托确认不可用;远征团与资源未改变。", "Contract confirmation is unavailable; Company and resources are unchanged.") + " [%s]" % reason func _dismissal_error_text(code: String) -> String: match code: "FINAL_LIVE_RECRUIT": return _tr("最后一名成员不可解雇;Company 没有改变。", "The final live member cannot be dismissed; Company is unchanged.") "DISMISS_FORMATION_LOCKED": return _tr("队列已锁定;请先返回未准备的 Market。", "The Line is locked; return to an unprepared Market first.") "DISMISS_MARKET_REQUIRED": return _tr("解雇只在 Market 可用;Company 没有改变。", "Dismissal is only available in Market; Company is unchanged.") "DISMISS_RECRUIT_UNAVAILABLE": return _tr("该成员已不在 Company;请重新查看成员列表。", "That Recruit is no longer in the Company; review the roster again.") return _tr("解雇未提交;金币与 Company 没有改变。", "Dismissal was not committed; Coin and Company are unchanged.") + " [%s]" % code func _contract_return_error_text(code: String) -> String: match code: "CONTRACT_RETURN_FORMATION_LOCKED": return _tr("队列已锁定;不能从准备中的委托直接返回。", "The Line is locked; a prepared Contract cannot be returned directly.") "CONTRACT_RETURN_ALREADY_APPLIED": return _tr("该委托已经返回公会;Company 没有重复改变。", "This Contract has already returned to Guild; Company was not changed again.") "CONTRACT_RETURN_MARKET_REQUIRED": return _tr("主动返回只在 Market 可用;Company 没有改变。", "Voluntary return is only available in Market; Company is unchanged.") return _tr("委托未返回;金币、成员与成长保持不变。", "The Contract was not returned; Coin, members and growth remain unchanged.") + " [%s]" % code func _hire_error_text(code: String) -> String: match code: "HIRE_PRICE_MISMATCH": return _tr("价格已改变;未扣除金币。", "The price changed; no Coin was charged.") "HIRE_INSUFFICIENT_COIN": return _tr("金币不足;未改变远征团。", "Insufficient Coin; Company state is unchanged.") "HIRE_OFFER_UNAVAILABLE": return _tr("该招募候选已不可用;未扣除金币。", "That Recruit offer is unavailable; no Coin was charged.") "OPENING_HIRES_COMPLETE": return _tr("开场两笔招募已完成;下一步建立编队。", "The two opening hires are complete; establish formation next.") return _tr("招募未完成;金币与远征团保持不变。", "Recruitment did not complete; Coin and Company are unchanged.") + " [%s]" % code func _formation_error_text(code: String) -> String: match code: "FORMATION_OPENING_HIRES_REQUIRED": return _tr("需要先完成两笔独立招募,并达到远征团 4/4。", "Complete both separate hires and reach Company 4/4 first.") "FORMATION_CAPACITY_STALE": return _tr("队列容量已改变;请返回市场重新检查。", "Line capacity changed; return to the Market and review it again.") "FORMATION_COUNT_INVALID": return _tr("当前队列人数与容量不一致。", "The current party count does not match Line capacity.") "FORMATION_RECRUIT_UNKNOWN", "FORMATION_RECRUIT_DUPLICATE": return _tr("队列成员已改变;请重新进入编队。", "Party membership changed; reopen formation.") return _tr("无法建立队列;远征团与资源没有改变。", "The line could not be established; Company and resources are unchanged.") + " [%s]" % code func _party_member_button(position: int, recruit: Dictionary) -> Button: var recruit_id := String(recruit.get("recruit_id", "")) var selected := recruit_id == party_selected_recruit_id var button := Button.new() button.name = "PartyPosition%d" % position button.flat = true button.clip_contents = true button.custom_minimum_size = Vector2(92, 128 if size.y < 500.0 else 190) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.set_meta("recruit_id", recruit_id) button.set_meta("position", position) button.tooltip_text = _tr("%d 号位置 · %s · 点选后再点目标位置交换", "Position %d · %s · select, then choose a destination to swap") % [position, _recruit_name(recruit)] for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var selection := ColorRect.new() selection.name = "SelectionGround" selection.anchor_left = 0.12 selection.anchor_top = 0.82 selection.anchor_right = 0.88 selection.anchor_bottom = 0.89 selection.color = Color("#73b9e7") if selected else Color("#8b7141") selection.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(selection) var portrait := TextureRect.new() portrait.name = "PartyPortrait" portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.08 portrait.anchor_top = 0.0 portrait.anchor_right = 0.92 portrait.anchor_bottom = 0.80 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(portrait) var position_label := _button_label("PositionLabel", _tr("%d 号", "POS %d") % position, _scaled(10), Color("#202a31") if selected else Color("#f5ead0")) position_label.anchor_top = 0.78 position_label.anchor_bottom = 0.90 position_label.offset_left = 6 position_label.offset_right = -6 button.add_child(position_label) var name := _button_label("NameLabel", _recruit_name(recruit), _scaled(10), Color("#f5ead0")) name.anchor_top = 0.89 name.offset_left = 3 name.offset_right = -3 button.add_child(name) button.pressed.connect(select_party_member.bind(recruit_id), CONNECT_DEFERRED) return button func _party_outline_label(label: Label) -> void: label.add_theme_color_override("font_outline_color", Color(0.02, 0.06, 0.08, 0.90)) label.add_theme_constant_override("outline_size", 2) func _party_line_top_rail() -> Control: var rail := Control.new() rail.custom_minimum_size = Vector2(0, 48 if size.y < 500.0 else 62) rail.mouse_filter = Control.MOUSE_FILTER_IGNORE var left := TextureRect.new() left.texture = GUILD_DESK_TOP_LEFT left.expand_mode = TextureRect.EXPAND_IGNORE_SIZE left.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED left.anchor_right = 0.11 left.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) left.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(left) var center := TextureRect.new() center.texture = GUILD_DESK_TOP_CENTER center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE center.stretch_mode = TextureRect.STRETCH_SCALE center.anchor_left = 0.09 center.anchor_right = 0.91 center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) center.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(center) var right := TextureRect.new() right.texture = GUILD_DESK_TOP_RIGHT right.expand_mode = TextureRect.EXPAND_IGNORE_SIZE right.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED right.anchor_left = 0.89 right.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) right.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(right) var identity := _label(_tr("队列编成", "PARTY LINE"), _scaled(11), Color("#f5ead0")) _party_outline_label(identity) identity.position = Vector2(42, 5) identity.size = Vector2(150, rail.custom_minimum_size.y - 10) identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER identity.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(identity) var status := _label(_tr("压力 %s · 远征团 %d/4", "PRESSURE %s · COMPANY %d/4") % [_difficulty_name(String(guild_company.active_contract.get("difficulty", "standard"))), guild_company.owned_recruits.size()], _scaled(9), Color("#f5ead0")) _party_outline_label(status) status.position = Vector2(210, 5) status.size = Vector2(maxf(180.0, size.x - 360.0), rail.custom_minimum_size.y - 10) status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER status.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(status) var company := _label(_tr("后排在左 · 0 号前排在右", "REAR LEFT · FRONT POSITION 0 RIGHT"), _scaled(9), Color("#f5ead0")) _party_outline_label(company) company.anchor_left = 0.73 company.anchor_right = 0.99 company.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) company.offset_top = 5 company.offset_bottom = -5 company.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER company.vertical_alignment = VERTICAL_ALIGNMENT_CENTER company.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(company) # Keep the formation rule available to automation and accessibility without # adding a second visible title to the compact rail. var orientation := Label.new() orientation.name = "PartyOrientation" orientation.text = _tr("后排在左 · 0 号前排在右", "REAR LEFT · FRONT POSITION 0 RIGHT") orientation.visible = false orientation.mouse_filter = Control.MOUSE_FILTER_IGNORE rail.add_child(orientation) return rail func _party_command_button(node_name: String, texture: Texture2D, label_text: String, callback: Callable, variant: String) -> Button: var button := Button.new() button.name = node_name button.flat = true button.custom_minimum_size = Vector2(78, 54) button.tooltip_text = label_text for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) button.add_child(_entry_chrome("Chrome", "company_primary" if variant == "primary" else "company_utility" if variant == "utility" else "company_secondary", "")) var icon := TextureRect.new() icon.name = "CommandIcon" icon.texture = texture icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS icon.anchor_left = 0.12 icon.anchor_top = 0.06 icon.anchor_right = 0.46 if variant != "primary" else 0.38 icon.anchor_bottom = 0.94 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(icon) var label_color := Color("#202a31") if variant == "utility" else Color("#f5ead0") var label := _button_label("ButtonLabel", label_text, _scaled(11 if variant != "primary" else 12), label_color) label.anchor_left = 0.39 if variant == "primary" else 0.38 label.anchor_right = 0.97 label.offset_left = 0 label.offset_right = 0 label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS button.add_child(label) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _readiness_member(position: int, recruit: Dictionary) -> Control: var member := Control.new() member.name = "ReadinessPosition%d" % position member.custom_minimum_size = Vector2(88, 126 if size.y < 500.0 else 180) member.size_flags_horizontal = Control.SIZE_EXPAND_FILL member.clip_contents = true var ground := ColorRect.new() ground.anchor_left = 0.14 ground.anchor_top = 0.80 ground.anchor_right = 0.86 ground.anchor_bottom = 0.86 ground.color = Color("#8b7141") ground.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(ground) var portrait := TextureRect.new() portrait.name = "ReadinessPortrait" portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.08 portrait.anchor_right = 0.92 portrait.anchor_bottom = 0.79 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(portrait) var position_label := _label(_tr("%d 号", "POS %d") % position, _scaled(9), Color("#e8d39d")) position_label.anchor_top = 0.79 position_label.anchor_right = 1.0 position_label.anchor_bottom = 0.90 position_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER position_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER member.add_child(position_label) var name := _label(_recruit_name(recruit), _scaled(9), Color("#f5ead0")) name.anchor_top = 0.89 name.anchor_right = 1.0 name.anchor_bottom = 1.0 name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name.vertical_alignment = VERTICAL_ALIGNMENT_CENTER name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS member.add_child(name) return member func _readiness_warning(formation: Dictionary) -> String: var coverage: Dictionary = formation.coverage var warnings: Array[String] = [] if int(coverage.protection) == 0: warnings.append(_tr("没有明确保护贡献者", "No explicit protection contributor")) if int(coverage.long_reach) == 0: warnings.append(_tr("没有整线射程", "No full-line reach")) if int(coverage.sustain) == 0: warnings.append(_tr("没有续航职责", "No sustain responsibility")) if warnings.is_empty(): return _tr("允许的警告:无。队列覆盖完整,但结果仍不保证。", "Allowed warnings: none. Coverage is complete, but the outcome is not guaranteed.") return _tr("允许继续的警告:", "Allowed warnings: ") + " · ".join(warnings) func _risk_option_button(option: Dictionary) -> Button: var option_id := String(option.risk_option_id) var selected := option_id == risk_selected_id var button := Button.new() button.name = "RiskOption%s" % option_id button.flat = true button.custom_minimum_size = Vector2(0, 54 if size.y < 500.0 else 66) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.tooltip_text = _risk_title(option) button.set_meta("selected", selected) for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var rail := ColorRect.new() rail.name = "RiskRail" rail.anchor_right = 1.0 rail.offset_left = 4 rail.offset_right = -4 rail.offset_top = 2 rail.offset_bottom = -2 rail.color = Color(0.09, 0.16, 0.21, 0.84 if selected else 0.52) rail.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(rail) var accent := ColorRect.new() accent.anchor_bottom = 1.0 accent.offset_left = 4 accent.offset_right = 10 accent.offset_top = 2 accent.offset_bottom = -2 accent.color = [Color("#75a884"), Color("#d6b76c"), Color("#b7584d")][int(option.risk)] accent.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(accent) var mark := _button_label("RiskMark", ["○", "△", "✦"][int(option.risk)], _scaled(18), Color("#f5ead0")) mark.anchor_right = 0.14 mark.offset_left = 12 button.add_child(mark) var title := _button_label("RiskTitle", _risk_title(option), _scaled(13), Color("#f5ead0")) title.anchor_left = 0.14 title.anchor_right = 0.48 title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT title.offset_left = 5 button.add_child(title) var summary := _button_label("RiskSummary", _risk_option_summary(option), _scaled(9), Color("#e8d39d")) summary.anchor_left = 0.48 summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT summary.offset_left = 5 summary.offset_right = -8 summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART button.add_child(summary) button.pressed.connect(select_risk_option.bind(option_id), CONNECT_DEFERRED) return button func _render_risk_context(context: VBoxContainer, formation: Dictionary) -> void: context.add_child(_label(_tr("选项检查", "OPTION INSPECT"), _scaled(10), Color("#e8d39d"))) if risk_selected_id.is_empty(): var empty := _label(_tr("选择一条风险查看完整已知信息。\n没有选项被自动选中或标记为推荐。", "Select one risk to inspect all known information.\nNo option is auto-selected or marked recommended."), _scaled(12), Color("#f5ead0")) empty.name = "RiskNoSelection" empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART empty.size_flags_vertical = Control.SIZE_EXPAND_FILL context.add_child(empty) return var option := _risk_option_by_id(formation, risk_selected_id) if option.is_empty(): context.add_child(_label(_tr("该选项已失效,请返回准备检查。", "That option is stale; return to Readiness."), _scaled(11), Color("#ffd4d4"))) return context.add_child(_label(_risk_title(option), _scaled(17), Color("#f0ca75"))) var detail := _label(_risk_detail_copy(option), _scaled(10), Color("#f5ead0")) detail.name = "RiskSelectedDetail" detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail.size_flags_vertical = Control.SIZE_EXPAND_FILL context.add_child(detail) var coverage: Dictionary = formation.coverage var coverage_copy := _label(_tr("当前覆盖:保护 %d · 远程 %d · 续航 %d · 控制 %d", "Current coverage: Guard %d · Reach %d · Sustain %d · Control %d") % [int(coverage.protection), int(coverage.long_reach), int(coverage.sustain), int(coverage.control)], _scaled(9), Color("#9ed7aa")) coverage_copy.name = "RiskCoverage" coverage_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(coverage_copy) func _risk_option_by_id(formation: Dictionary, option_id: String) -> Dictionary: for option in formation.get("risk_options", []): if String(option.get("risk_option_id", "")) == option_id: return Dictionary(option) return {} func _risk_title(option: Dictionary) -> String: match String(option.get("tier_id", "steady")): "pressured": return _tr("受压路线", "PRESSURED ROUTE") "elite": return _tr("精英路线", "ELITE ROUTE") return _tr("稳健路线", "STEADY ROUTE") func _risk_option_summary(option: Dictionary) -> String: return _tr("%d 金币 · %s · %s", "%d Coin · %s · %s") % [int(option.reward_coin), _uncertainty_name(String(option.uncertainty_id)), _recovery_name(String(option.recovery_exposure_id))] func _risk_threat_copy(option: Dictionary) -> String: var threat: Dictionary = option.known_threat return _tr("已知:%d 名敌人 · %d 波 · 首要目标 0 号", "Known: %d enemies · %d waves · primary target position 0") % [int(threat.enemy_count), int(threat.wave_count)] func _risk_detail_copy(option: Dictionary) -> String: return _tr( "已知威胁:%d 名敌人 · %d 波 · 0 号为首要目标\n未解析信息:%s\n入场成本:%d 金币\n胜利回报:%d 金币 · %s\n恢复暴露:%s", "Known threat: %d enemies · %d waves · position 0 is primary\nUncertainty: %s\nEntry cost: %d Coin\nVictory reward: %d Coin · %s\nRecovery exposure: %s" ) % [ int(option.known_threat.enemy_count), int(option.known_threat.wave_count), _uncertainty_name(String(option.uncertainty_id)), int(option.entry_cost), int(option.reward_coin), _reward_class_name(String(option.reward_class_id)), _recovery_name(String(option.recovery_exposure_id)), ] func _risk_uncertainty_short(value: String) -> String: match value: "one_intent_unresolved": return _tr("1项未解析", "1 INTENT OPEN") "elite_response_unresolved": return _tr("精英时机未解析", "ELITE TIMING OPEN") return _tr("全公开", "CLEAR") func _risk_reward_short(value: String) -> String: match value: "enhanced_choice": return _tr("强化选择", "ENHANCED") "elite_cache": return _tr("精英宝箱", "ELITE CACHE") return _tr("标准补给", "STANDARD") func _uncertainty_name(value: String) -> String: match value: "one_intent_unresolved": return _tr("一项敌方意图未解析", "one enemy intent unresolved") "elite_response_unresolved": return _tr("精英反应时机未解析", "elite response timing unresolved") return _tr("全部公开", "fully disclosed") func _recovery_name(value: String) -> String: match value: "moderate": return _tr("中", "moderate") "high": return _tr("高", "high") return _tr("低", "low") func _reward_class_name(value: String) -> String: match value: "enhanced_choice": return _tr("强化选择", "enhanced choice") "elite_cache": return _tr("精英宝箱", "elite cache") return _tr("标准补给", "standard supplies") func _battle_enemy_marker(index: int, enemy_id: String, battle: Dictionary) -> Control: var marker := Control.new() marker.name = "BattleEnemy%d" % index marker.custom_minimum_size = Vector2(66, 54 if size.y < 500.0 else 82) marker.mouse_filter = Control.MOUSE_FILTER_IGNORE var silhouette := ColorRect.new() silhouette.anchor_left = 0.24 silhouette.anchor_top = 0.10 silhouette.anchor_right = 0.76 silhouette.anchor_bottom = 0.72 silhouette.color = Color("#7b3f3f") if index == 0 else Color("#5c4e50") silhouette.mouse_filter = Control.MOUSE_FILTER_IGNORE marker.add_child(silhouette) var label := _label("E%d" % (index + 1), _scaled(11), Color("#f5ead0")) label.anchor_right = 1.0 label.anchor_top = 0.70 label.anchor_bottom = 1.0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER marker.add_child(label) return marker func _battle_actor(position: int, recruit: Dictionary, battle: Dictionary) -> Control: var actor := Control.new() actor.name = "BattleActor%d" % position actor.custom_minimum_size = Vector2(88, 142 if size.y < 500.0 else 220) actor.size_flags_horizontal = Control.SIZE_EXPAND_FILL actor.clip_contents = true var portrait := TextureRect.new() portrait.name = "BattlePortrait" portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.06 portrait.anchor_top = 0.0 portrait.anchor_right = 0.94 portrait.anchor_bottom = 0.76 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(portrait) var ground := ColorRect.new() ground.anchor_left = 0.10 ground.anchor_top = 0.74 ground.anchor_right = 0.90 ground.anchor_bottom = 0.79 ground.color = Color("#b7584d") if String(recruit.recruit_id) in Array(battle.get("knocked_out_ids", [])) else Color("#75a884") ground.mouse_filter = Control.MOUSE_FILTER_IGNORE actor.add_child(ground) var position_label := _label(_tr("%d 号", "POS %d") % position, _scaled(9), Color("#e8d39d")) position_label.anchor_top = 0.78 position_label.anchor_right = 1.0 position_label.anchor_bottom = 0.89 position_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER position_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER actor.add_child(position_label) var name := _label(_recruit_name(recruit), _scaled(9), Color("#f5ead0")) name.anchor_top = 0.88 name.anchor_right = 1.0 name.anchor_bottom = 1.0 name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name.vertical_alignment = VERTICAL_ALIGNMENT_CENTER name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS actor.add_child(name) return actor func _render_battle_event_context(context: VBoxContainer, company: Dictionary, battle: Dictionary) -> void: context.add_child(_label(_tr("事件投影", "EVENT PROJECTION"), _scaled(10), Color("#e8d39d"))) var events: Array = battle.get("phase_events", []) if events.is_empty(): context.add_child(_label(_tr("正在等待第一条事件...", "Waiting for the first event..."), _scaled(12), Color("#f5ead0"))) else: var event: Dictionary = events.back() var title := _label(_battle_event_text(String(event.get("caption_id", "battle_opening"))), _scaled(16), Color("#f0ca75")) title.name = "BattleCurrentEvent" context.add_child(title) var actor_name := _recruit_name(_recruit_by_id(company, String(event.get("actor_id", "")))) if not String(event.get("actor_id", "")).is_empty() else _tr("敌方队列", "Enemy line") var target_name := _recruit_name(_recruit_by_id(company, String(event.get("target_id", "")))) if _recruit_by_id(company, String(event.get("target_id", ""))).size() > 0 else _tr("敌方目标", "Enemy target") var detail := _label(_tr("行动者:%s\n目标:%s\n事件类型:%s", "Actor: %s\nTarget: %s\nEvent: %s") % [actor_name, target_name, String(event.get("kind", ""))], _scaled(11), Color("#f5ead0")) detail.name = "BattleEventDetail" detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail.size_flags_vertical = Control.SIZE_EXPAND_FILL context.add_child(detail) var recent: Array[String] = [] for event in events.slice(maxi(0, events.size() - 4), events.size()): recent.append(_battle_event_text(String(event.get("caption_id", "")))) var rail := _label("\n".join(recent), _scaled(9), Color("#aebbc2")) rail.name = "BattleEventRail" rail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(rail) func _battle_event_text(caption_id: String) -> String: match caption_id: "incoming_threat": return _tr("敌方威胁已公开", "Incoming threat disclosed") "protection_intercepts": return _tr("保护关系承接压力", "Protection relation intercepts pressure") "ranged_contact": return _tr("远程接触命中", "Ranged contact lands") "magic_break": return _tr("魔法破坏命中", "Magic break lands") "healing_link": return _tr("治疗链接恢复队列", "Healing link restores the line") "decisive_damage": return _tr("决定性伤害改变结果", "Decisive damage changes the result") "knockout": return _tr("成员在本战斗中被击倒", "A member is knocked out for this battle") "line_holds": return _tr("队列守住压力", "The line holds pressure") "victory": return _tr("胜利结果已准备", "Victory result is ready") return _tr("战斗开场", "Battle opening") func _battle_outcome_text(outcome: String) -> String: return _tr("胜利", "VICTORY") if outcome == "victory" else _tr("失败", "DEFEAT") if outcome == "defeat" else _tr("结果待核对", "RESULT NEEDS REVIEW") func _battle_result_decisive_event(battle: Dictionary) -> Dictionary: var result: Dictionary = battle.get("result", {}) var decisive_hash := String(result.get("decisive_event_hash", "")) for event in battle.get("phase_events", []): if String(event.get("event_hash", "")) == decisive_hash: return Dictionary(event) for event in battle.get("phase_events", []): if String(event.get("kind", "")) in ["damage", "knockout"]: return Dictionary(event) return {} func _battle_result_knockout_text(company: Dictionary, knockouts: Array) -> String: if knockouts.is_empty(): return _tr("击倒:无 · 四名成员保留战斗状态", "Knockout: none · all four members retain battle state") var lines: Array[String] = [] for recruit_id in knockouts: var recruit := _recruit_by_id(company, String(recruit_id)) if recruit.is_empty(): lines.append(_tr("击倒成员:身份回执缺失", "Knocked-out member: identity receipt missing")) continue lines.append(_tr("击倒:%s · %s · 仅本战斗离场,下关归队", "Knocked out: %s · %s · battle-only absence; returns next stage") % [_recruit_name(recruit), _recruit_profession_name(recruit)]) return "\n".join(lines) func _battle_result_chain_summary(battle: Dictionary) -> String: var events: Array = battle.get("phase_events", []) if events.is_empty(): return _tr("因果数据缺失。结果保持暂停。", "Causal data is missing. Result remains paused.") var lines: Array[String] = [] for event in events: if String(event.get("kind", "")) in ["opening", "damage", "knockout", "guarded_recovery", "result"]: lines.append(_battle_event_text(String(event.get("caption_id", "")))) return " > ".join(lines) func _battle_result_diagnosis_text(company: Dictionary, battle: Dictionary) -> String: var decisive := _battle_result_decisive_event(battle) var result: Dictionary = battle.get("result", {}) var lines: Array[String] = [] lines.append(_tr("提交队列:%d 名成员 · 敌方:%d 名", "Committed line: %d members · enemy line: %d") % [battle.get("front_to_rear_ids", []).size(), battle.get("enemy_ids", []).size()]) lines.append(_tr("首要目标:0 号前排 · 决定性事件:%s", "First target: position 0 front · decisive event: %s") % _battle_event_text(String(decisive.get("caption_id", "")))) lines.append(_tr("事件 hash:%s", "Event hash: %s") % String(decisive.get("event_hash", "")).substr(0, 12)) lines.append(_tr("奖励回执:%d 金币 · XP 总计 %d", "Reward receipt: %d Coin · %d total XP") % [int(result.get("reward_coin", 0)), int(result.get("xp_award", 0))]) return "\n".join(lines) func _battle_result_knockout_text_from_view(view_data: Dictionary) -> String: var knockouts: Dictionary = Dictionary(view_data.get("knockouts", {})) var identities: Array = knockouts.get("identities", []) if identities.is_empty(): return _tr("击倒:无 · 四名成员保留战斗状态", "Knockout: none · all four members retain battle state") var lines: Array[String] = [] for identity in identities: if not identity is Dictionary: continue var recruit: Dictionary = Dictionary(identity.get("recruit", {})) if recruit.is_empty(): lines.append(_tr("击倒成员:身份回执缺失", "Knocked-out member: identity receipt missing")) continue lines.append(_tr("击倒:%s · %s · 仅本战斗离场,下关归队", "Knocked out: %s · %s · battle-only absence; returns next stage") % [_recruit_name(recruit), _recruit_profession_name(recruit)]) return "\n".join(lines) func _battle_result_chain_summary_from_view(view_data: Dictionary) -> String: var causal_chain: Dictionary = Dictionary(view_data.get("causal_chain", {})) var events: Array = causal_chain.get("events", []) if events.is_empty(): return _tr("因果数据缺失。结果保持暂停。", "Causal data is missing. Result remains paused.") var lines: Array[String] = [] for event in events: if event is Dictionary and String(event.get("kind", "")) in ["opening", "damage", "knockout", "guarded_recovery", "result"]: lines.append(_battle_event_text(String(event.get("caption_id", "")))) return " > ".join(lines) func _battle_result_diagnosis_text_from_view(view_data: Dictionary) -> String: var battle: Dictionary = Dictionary(view_data.get("battle", {})) var decisive: Dictionary = Dictionary(view_data.get("decisive_event", {})) var economy: Dictionary = Dictionary(view_data.get("economy", {})) var lines: Array[String] = [] lines.append(_tr("提交队列:%d 名成员 · 敌方:%d 名", "Committed line: %d members · enemy line: %d") % [Array(battle.get("front_to_rear_ids", [])).size(), Array(battle.get("enemy_ids", [])).size()]) lines.append(_tr("首要目标:0 号前排 · 决定性事件:%s", "First target: position 0 front · decisive event: %s") % _battle_event_text(String(decisive.get("caption_id", "")))) lines.append(_tr("事件 hash:%s", "Event hash: %s") % String(decisive.get("event_hash", "")).substr(0, 12)) lines.append(_tr("奖励回执:%d 金币 · XP 总计 %d", "Reward receipt: %d Coin · %d total XP") % [int(economy.get("reward_coin", 0)), int(economy.get("xp_award", 0))]) return "\n".join(lines) func _battle_error_text(code: String) -> String: match code: "BATTLE_READINESS_REQUIRED": return _tr("请先接受准备检查。", "Accept Readiness first.") "BATTLE_RISK_UNAVAILABLE": return _tr("该风险选项已失效,请返回风险板。", "That risk option is stale; return to the Risk Board.") "BATTLE_ALREADY_ACTIVE": return _tr("已有进行中的战斗;已恢复原战斗回执。", "A battle is already active; its original receipt was restored.") "BATTLE_OUTCOME_REQUIRED", "OBSERVATION_NOT_READY_FOR_RESULT", "OBSERVATION_OUTCOME_LOCK_MISSING": return _tr("结果尚未锁定,无法进入结算。", "The outcome is not locked yet; settlement is unavailable.") "OBSERVATION_RESULT_INVALID": return _tr("结果事件或日志校验失败;战斗保持不变。", "The result event or journal failed validation; battle state is unchanged.") "REWARD_RESULT_REQUIRED": return _tr("请先完成结果结算。", "Complete result settlement first.") "REWARD_DRAFT_MISSING": return _tr("本场没有可用的奖励草案。", "No reward draft is available for this battle.") "REWARD_OPTION_UNAVAILABLE", "REWARD_ALREADY_APPLIED": return _tr("该奖励选择已失效;请读取原回执。", "That reward choice is stale; read the original receipt.") "REWARD_OPERATION_CONFLICT": return _tr("奖励操作 ID 与原意图冲突;没有重复发放。", "The reward operation conflicts with its original intent; nothing was duplicated.") "RECOVERY_REWARD_REQUIRED": return _tr("请先领取或跳过本场奖励。", "Claim or skip this battle's reward first.") "RECOVERY_RESULT_REQUIRED": return _tr("结果回执尚未完成,无法恢复下一阶段。", "The result receipt is not ready for next-stage recovery.") "recovery_already_applied": return _tr("恢复已提交;正在打开下一阶段市场。", "Recovery was already committed; opening the next-stage Market.") return _tr("战斗回执未能创建;远征团没有改变。", "The battle receipt could not be created; Company state is unchanged.") + " [%s]" % code func _render_party_context(context: VBoxContainer, company: Dictionary) -> void: context.add_child(_label(_tr("位置关系", "POSITION RELATIONS"), _scaled(10), Color("#6a4b29"))) var context_id := party_selected_recruit_id if not party_selected_recruit_id.is_empty() else String(party_draft_order[0]) var recruit := _recruit_by_id(company, context_id) var relation := _party_relation(context_id) var name := _label(_recruit_name(recruit), _scaled(17), Color("#26343a")) name.name = "PartyContextName" context.add_child(name) var position := int(relation.get("position", 0)) var position_copy := _label( _tr("%d 号 · %s", "POSITION %d · %s") % [position, _exposure_name(String(relation.get("exposure_id", "highest")))], _scaled(11), Color("#8f5e28") ) position_copy.name = "PartyContextPosition" context.add_child(position_copy) var protected_names: Array[String] = [] for recruit_id in relation.get("protected_by_recruit_ids", []): protected_names.append(_recruit_name(_recruit_by_id(company, String(recruit_id)))) var protection_copy := _tr("无前方保护者", "No protector ahead") if protected_names.is_empty() else _tr("受到保护:", "Protected by: ") + " / ".join(protected_names) var target_copy := _tr("首要威胁目标", "Primary threat target") if bool(relation.get("first_threat_target", false)) else _tr("预期目标顺位 %d", "Expected target rank %d") % int(relation.get("expected_target_rank", position + 1)) var reach_copy := _reach_name(String(relation.get("reach_id", "limited"))) var summary := _label("%s\n%s\n%s" % [target_copy, protection_copy, reach_copy], _scaled(11), Color("#4a5b5d")) summary.name = "PartyRelationSummary" summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART summary.size_flags_vertical = Control.SIZE_EXPAND_FILL context.add_child(summary) var coverage: Dictionary = party_preview.get("coverage", {}) var coverage_copy := _label( _tr("队列覆盖 保护 %d · 远程 %d · 续航 %d · 控制 %d", "LINE COVERAGE Guard %d · Reach %d · Sustain %d · Control %d") % [ int(coverage.get("protection", 0)), int(coverage.get("long_reach", 0)), int(coverage.get("sustain", 0)), int(coverage.get("control", 0)) ], _scaled(10), Color("#3f765d") ) coverage_copy.name = "PartyCoverage" coverage_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(coverage_copy) var instruction := _label( party_notice if not party_notice.is_empty() else _tr("先点一名成员,再点目标位置。选择只在当前预览中变化。", "Select a member, then a destination. Changes remain in this preview only."), _scaled(10), Color("#3f6d79") ) instruction.name = "PartyInstruction" instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(instruction) var next_boundary := _label(_tr("尚未保存 · 准备检查将在下一步启用", "NOT SAVED · READINESS REVIEW ENABLES NEXT"), _scaled(9), Color("#6a4b29")) next_boundary.name = "PartyReviewReason" next_boundary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART context.add_child(next_boundary) func _party_relation(recruit_id: String) -> Dictionary: var preview_data: Dictionary = party_view_data.get("preview", party_preview) if not party_view_data.is_empty() else party_preview for relation in preview_data.get("relations", []): if String(relation.get("recruit_id", "")) == recruit_id: return Dictionary(relation) return {} func _recruit_by_id(company: Dictionary, recruit_id: String) -> Dictionary: for recruit in company.get("owned_recruits", []): if String(recruit.get("recruit_id", "")) == recruit_id: return Dictionary(recruit) return {} func _recruit_name(recruit: Dictionary) -> String: var localized: Dictionary = recruit.get("rendered_name_snapshot", {}) var locale := String(settings.get("locale", "zh_CN")) return String(localized.get(locale, localized.get("en", recruit.get("recruit_id", "Unknown")))) func _recruit_profession_name(recruit: Dictionary) -> String: var localized: Dictionary = recruit.get("profession_name", {}) var locale := String(settings.get("locale", "zh_CN")) return String(localized.get(locale, localized.get("en", recruit.get("profession_id", "Unknown Profession")))) func _recruit_trait_name(recruit: Dictionary) -> String: var localized: Dictionary = recruit.get("trait_name", {}) var locale := String(settings.get("locale", "zh_CN")) return String(localized.get(locale, localized.get("en", recruit.get("trait_id", "Unknown Trait")))) func _recruit_item_name(recruit: Dictionary) -> String: if String(recruit.get("equipped_item_id", "")) != String(recruit.get("starting_item_id", "")): return _content_name("equipment", String(recruit.get("equipped_item_id", ""))) var localized: Dictionary = recruit.get("starting_item_name", {}) var locale := String(settings.get("locale", "zh_CN")) return String(localized.get(locale, localized.get("en", recruit.get("equipped_item_id", "Unknown Equipment")))) func _recovery_identity_lines(company: Dictionary, recruit_ids: Array) -> Array[String]: var lines: Array[String] = [] for recruit_id in recruit_ids: var recruit := _recruit_by_id(company, String(recruit_id)) if recruit.is_empty(): lines.append(_tr("身份回执缺失:%s", "Identity receipt missing: %s") % String(recruit_id)) continue lines.append(_tr("%s · %s · ID %s", "%s · %s · ID %s") % [_recruit_name(recruit), _recruit_profession_name(recruit), String(recruit.recruit_id).substr(0, 8)]) return lines func _recovery_identity_lines_from_view(view_data: Dictionary) -> Array[String]: var lines: Array[String] = [] var knockouts: Dictionary = Dictionary(view_data.get("knockouts", {})) for identity in knockouts.get("identities", []): if not identity is Dictionary: continue var recruit: Dictionary = Dictionary(identity.get("recruit", {})) if recruit.is_empty(): lines.append(_tr("身份回执缺失", "Identity receipt missing")) continue lines.append(_tr("%s · %s · ID %s", "%s · %s · ID %s") % [_recruit_name(recruit), _recruit_profession_name(recruit), String(recruit.get("recruit_id", "")).substr(0, 8)]) return lines func _exposure_name(exposure_id: String) -> String: match exposure_id: "rear": return _tr("受保护后排", "Protected rear") "guarded": return _tr("受保护中段", "Guarded center") "high": return _tr("高暴露", "High exposure") return _tr("最高暴露", "Highest exposure") func _reach_name(reach_id: String) -> String: match reach_id: "full_line": return _tr("射程:覆盖整条队列", "Reach: full line") "front_three": return _tr("射程:覆盖前 3 位", "Reach: front three") "front_two": return _tr("射程:覆盖前 2 位", "Reach: front two") return _tr("射程受限:调整到更前位置", "Limited reach: move farther forward") func _same_string_members(left: Array, right: Array) -> bool: if left.size() != right.size(): return false var left_ids: Array[String] = [] var right_ids: Array[String] = [] for value in left: left_ids.append(String(value)) for value in right: right_ids.append(String(value)) left_ids.sort() right_ids.sort() return left_ids == right_ids func _set_normalized_rect(control: Control, rect: Rect2) -> void: control.anchor_left = rect.position.x control.anchor_top = rect.position.y control.anchor_right = rect.end.x control.anchor_bottom = rect.end.y control.offset_left = 0 control.offset_top = 0 control.offset_right = 0 control.offset_bottom = 0 func _guild_master_member(index: int, recruit: Dictionary, texture: Texture2D, rect: Rect2) -> Control: var member := Control.new() member.name = "GuildMember_%d" % index _set_normalized_rect(member, rect) member.mouse_filter = Control.MOUSE_FILTER_IGNORE var shadow := ColorRect.new() shadow.name = "GuildMemberGround_%d" % index shadow.anchor_left = 0.16 shadow.anchor_top = 0.86 shadow.anchor_right = 0.84 shadow.anchor_bottom = 0.91 shadow.color = Color(0.02, 0.08, 0.08, 0.28) shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(shadow) var portrait := TextureRect.new() portrait.name = "GuildMemberPortraitTexture_%d" % index portrait.texture = texture if texture != null else InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) portrait.offset_bottom = -20 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(portrait) var name := _label(_recruit_name(recruit), _scaled(11), Color("#fff4d6")) name.name = "GuildMemberName_%d" % index name.anchor_top = 0.88 name.anchor_right = 1.0 name.anchor_bottom = 1.0 name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name.vertical_alignment = VERTICAL_ALIGNMENT_CENTER name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS name.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(name) return member func _guild_desk_member(index: int, recruit: Dictionary, rect: Rect2) -> Control: var member := Control.new() member.name = "GuildMember_%d" % index _set_normalized_rect(member, rect) member.mouse_filter = Control.MOUSE_FILTER_IGNORE var shadow := ColorRect.new() shadow.name = "GuildMemberGround_%d" % index shadow.anchor_left = 0.18 shadow.anchor_top = 0.88 shadow.anchor_right = 0.82 shadow.anchor_bottom = 0.915 shadow.color = Color(0.02, 0.08, 0.08, 0.24) shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(shadow) var portrait := TextureRect.new() portrait.name = "GuildMemberPortraitTexture_%d" % index portrait.texture = InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE member.add_child(portrait) return member func _guild_master_kiosk_button(node_name: String, kind: String, title: String, callback: Callable, rect: Rect2) -> Button: var button := Button.new() button.name = node_name button.flat = true button.tooltip_text = title _set_normalized_rect(button, rect) for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var kiosk := TextureRect.new() kiosk.name = "GuildMasterKioskTexture" kiosk.texture = GUILD_MASTER_KIOSK if kind == "contract" else GUILD_MASTER_COMPASS kiosk.expand_mode = TextureRect.EXPAND_IGNORE_SIZE kiosk.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED kiosk.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS kiosk.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) kiosk.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(kiosk) var marker := Control.new() marker.name = "Guild%sSticker" % kind.capitalize() marker.visible = false button.add_child(marker) var label := _label(title, _scaled(16), Color("#27323a")) label.anchor_left = 0.30 label.anchor_top = 0.43 label.anchor_right = 0.78 label.anchor_bottom = 0.66 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _guild_master_hit_button(node_name: String, tooltip: String, callback: Callable, rect: Rect2) -> Button: var button := Button.new() button.name = node_name button.flat = true button.tooltip_text = tooltip _set_normalized_rect(button, rect) for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _guild_master_overlay_plate(parent: Control, node_name: String, rect: Rect2, background: Color, border: Color, border_width: int, radius: int) -> Panel: var plate := Panel.new() plate.name = node_name _set_normalized_rect(plate, rect) plate.mouse_filter = Control.MOUSE_FILTER_IGNORE plate.add_theme_stylebox_override("panel", _flat_style(background, border, border_width, radius)) parent.add_child(plate) return plate func _guild_master_rail_value(parent: Control, node_name: String, rect: Rect2, value: String) -> Label: var plate := _guild_master_overlay_plate(parent, "%sPlate" % node_name, rect, Color(0.025, 0.09, 0.14, 0.97), Color(0.62, 0.46, 0.23, 0.72), 1, 2) var label := _label(value, _scaled(8), Color("#f1d48b")) label.name = node_name label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE plate.add_child(label) return label func _guild_master_command_label(parent: Control, node_name: String, rect: Rect2, value: String) -> Label: var plate := _guild_master_overlay_plate(parent, "%sPlate" % node_name, rect, Color(0.025, 0.09, 0.14, 0.94), Color(0.025, 0.09, 0.14, 0.0), 0, 1) var label := _label(value, _scaled(7), Color("#f1d48b")) label.name = node_name label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE plate.add_child(label) return label func _guild_member_column(index: int, recruit: Dictionary, texture_override: Texture2D = null) -> Control: var box := Control.new() box.name = "GuildMember_%d" % index box.custom_minimum_size = Vector2(150, 180 if size.y < 500.0 else 230) box.size_flags_horizontal = Control.SIZE_EXPAND_FILL box.size_flags_vertical = Control.SIZE_EXPAND_FILL box.size_flags_stretch_ratio = 1.0 box.mouse_filter = Control.MOUSE_FILTER_IGNORE var member_tag := _label(_tr("远征团成员 %d" % (index + 1), "COMPANY MEMBER %d" % (index + 1)), _scaled(9), Color("#f5ead0")) member_tag.position = Vector2(8, 2) member_tag.size = Vector2(220, 20) member_tag.visible = false member_tag.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(member_tag) var portrait_stage := Control.new() portrait_stage.name = "GuildMemberPortrait_%d" % index portrait_stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) portrait_stage.offset_top = -8 portrait_stage.offset_bottom = -24 portrait_stage.mouse_filter = Control.MOUSE_FILTER_IGNORE var ground := ColorRect.new() ground.name = "GuildMemberGround_%d" % index ground.anchor_left = 0.08 ground.anchor_top = 0.84 ground.anchor_right = 0.92 ground.anchor_bottom = 0.92 ground.color = Color(0.04, 0.13, 0.13, 0.38) ground.mouse_filter = Control.MOUSE_FILTER_IGNORE portrait_stage.add_child(ground) var tint := ColorRect.new() tint.color = Color(InitialCompanyArt.accent_for(recruit), 0.06) tint.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) tint.mouse_filter = Control.MOUSE_FILTER_IGNORE portrait_stage.add_child(tint) var portrait := TextureRect.new() portrait.name = "GuildMemberPortraitTexture_%d" % index portrait.texture = texture_override if texture_override != null else InitialCompanyArt.texture_for(recruit) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.anchor_left = 0.16 portrait.anchor_top = 0.02 portrait.anchor_right = 0.84 portrait.anchor_bottom = 0.96 portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE portrait_stage.add_child(portrait) box.add_child(portrait_stage) var locale := String(settings.get("locale", "zh_CN")) var name := _label(String(recruit.rendered_name_snapshot.get(locale, recruit.rendered_name_snapshot.en)), _scaled(15), Color("#f5ead0")) name.anchor_top = 0.86 name.anchor_right = 1.0 name.anchor_bottom = 0.99 name.offset_left = 8 name.offset_right = -8 name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS name.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(name) var profession := _label("%s · %s" % [String(recruit.profession_name.get(locale, recruit.profession_name.en)), _tr("等级 1", "Level 1")], _scaled(10), Color("#f0ca75")) profession.anchor_top = 0.84 profession.anchor_right = 1.0 profession.anchor_bottom = 0.93 profession.offset_left = 8 profession.offset_right = -8 profession.visible = false profession.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(profession) var trait_label := _label(String(recruit.trait_name.get(locale, recruit.trait_name.en)), _scaled(10), Color("#b9dfc0")) trait_label.anchor_top = 0.91 trait_label.anchor_right = 1.0 trait_label.anchor_bottom = 0.99 trait_label.offset_left = 8 trait_label.offset_right = -8 trait_label.visible = false trait_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS trait_label.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(trait_label) return box func _guild_destination_button(node_name: String, kind: String, title: String, callable: Callable) -> Button: var button := _entry_button_base(node_name, callable, "content_destination") button.custom_minimum_size = Vector2(250, 66) var sticker = GuildDestinationSticker.new().configure(kind) sticker.name = "Guild%sSticker" % kind.capitalize() sticker.position = Vector2(10, 6) sticker.size = Vector2(82, 54) sticker.visible = false button.add_child(sticker) var prop := TextureRect.new() prop.name = "Guild%sProp" % kind.capitalize() prop.texture = GUILD_CONTRACT_BOARD if kind == "contract" else GUILD_ROUTE_MAP prop.expand_mode = TextureRect.EXPAND_IGNORE_SIZE prop.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED prop.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS prop.position = Vector2(8, 4) prop.size = Vector2(88, 58) prop.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(prop) var label := _label(title, _scaled(16), Color("#24313a")) label.position = Vector2(104, 0) label.size = Vector2(maxf(80.0, button.custom_minimum_size.x - 116.0), 66) label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) return button func _company_button(node_name: String, text_value: String, callback: Callable, variant: String, icon: String) -> Button: var button := UIFactory.command_button( node_name, text_value, variant, icon, "angular", "content" if variant.begins_with("content_") else "company", Vector2(190, 52), false, text_value, callback, _scaled(16) ) return button func _guild_anchor_icon_button(node_name: String, tooltip: String, callback: Callable, icon: String) -> Button: var button := Button.new() button.name = node_name button.flat = true button.custom_minimum_size = Vector2(62, 50) button.tooltip_text = tooltip for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var medallion := TextureRect.new() medallion.name = "GuildMasterMedallion" medallion.texture = GUILD_MASTER_MEDALLION medallion.expand_mode = TextureRect.EXPAND_IGNORE_SIZE medallion.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED medallion.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS medallion.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) medallion.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(medallion) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _guild_anchor_prop_button(node_name: String, kind: String, title: String, callback: Callable) -> Button: var button := Button.new() button.name = node_name button.flat = true button.custom_minimum_size = Vector2(280, 150) button.tooltip_text = title for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) var kiosk_texture := TextureRect.new() kiosk_texture.name = "GuildMasterKioskTexture" kiosk_texture.texture = GUILD_MASTER_KIOSK if kind == "contract" else GUILD_MASTER_COMPASS kiosk_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE kiosk_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED kiosk_texture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS kiosk_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) kiosk_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(kiosk_texture) var marker := Control.new() marker.name = "Guild%sSticker" % kind.capitalize() marker.visible = false button.add_child(marker) var prop := TextureRect.new() prop.name = "Guild%sProp" % kind.capitalize() prop.texture = GUILD_CONTRACT_BOARD if kind == "contract" else GUILD_ROUTE_MAP prop.expand_mode = TextureRect.EXPAND_IGNORE_SIZE prop.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED prop.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS prop.position = Vector2(18, 28) prop.size = Vector2(80, 54) prop.visible = kind != "contract" prop.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(prop) var label := _label(title, _scaled(17), Color("#f4e4bd")) label.position = Vector2(84, 76) label.size = Vector2(176, 42) label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _set_guild_notice(value: String) -> void: guild_notice = value var label := get_node_or_null("GuildHandoffSafeArea/GuildHandoffLayout/GuildHandoffBody/GuildNextActions/GuildNotice") as Label if label == null: label = find_child("GuildNotice", true, false) as Label if label != null: label.text = value func show_settings_from_guild() -> void: settings_caller = "guild_handoff" show_settings() func show_settings_placeholder() -> void: settings_caller = "title" show_settings() func show_credits_placeholder() -> void: settings_caller = "title" show_credits() func _settings_return_screen_id() -> String: match settings_caller: "guild_handoff": return "guild_handoff" "readiness": return "readiness_review" "risk_board": return "risk_board" "risk_commitment": return "risk_commitment" "market": return "market" "recruit_review": return "recruit_review" "battle_opening": return "battle" "battle_live", "battle_pause_controls": return "battle" return "title" func _settings_return_focus_path() -> String: match settings_caller: "guild_handoff": return "GuildSettings" "readiness": return "ReadinessSettings" "risk_board": return "RiskSettings" "risk_commitment": return "RiskCommitmentSettings" "market": return "MarketSettings" "recruit_review": return "RecruitReviewSettings" "battle_opening": return "BattleOpeningSettings" "battle_live", "battle_pause_controls": return "BattleLiveSettings" return "TitleSettings" func _open_settings_context() -> void: settings_context = ScreenContextScript.new({ "screen_id": "settings", "caller_id": settings_caller, "return_screen_id": _settings_return_screen_id(), "return_focus_path": _settings_return_focus_path(), "presentation_mode": "page", "metadata": {"category": settings_category}, }) var current = app_services.screen_stack.current() if current != null and String(current.screen_id) == "settings": app_services.screen_stack.replace(settings_context) else: app_services.screen_stack.push(settings_context) func _close_settings_context() -> Variant: var context = settings_context settings_context = null var current = app_services.screen_stack.current() if current != null and String(current.screen_id) == "settings": app_services.screen_stack.pop() return context func _restore_settings_focus(focus_name: String) -> void: if focus_name.is_empty(): return var target := find_child(focus_name, true, false) as Control if target == null or target.focus_mode == Control.FOCUS_NONE: return if target is BaseButton and (target as BaseButton).disabled: return target.grab_focus() func show_settings(category := "") -> void: if settings_draft.is_empty() or not category.is_empty(): settings_draft = settings.duplicate(true) if not category.is_empty(): settings_category = category _open_settings_context() current_screen = "settings" _build_world_base() var safe := MarginContainer.new() safe.name = "SettingsSafeArea" safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) var margin := int(settings_draft.get("safe_area", 24)) + 20 safe.add_theme_constant_override("margin_left", margin) safe.add_theme_constant_override("margin_right", margin) var short_layout := size.y < 500.0 safe.add_theme_constant_override("margin_top", 12 if short_layout else 24) safe.add_theme_constant_override("margin_bottom", 12 if short_layout else 24) add_child(safe) safe.add_child(_entry_chrome("SettingsFrame", "settings_frame")) var outer := VBoxContainer.new() outer.name = "SettingsOuter" outer.add_theme_constant_override("separation", 8) safe.add_child(outer) var heading := HBoxContainer.new() heading.name = "SettingsHeading" outer.add_child(heading) var title := _label(_tr("设置", "SETTINGS"), _scaled(24 if short_layout else 26), Color("#f5ead0")) title.size_flags_horizontal = Control.SIZE_EXPAND_FILL heading.add_child(title) heading.add_child(_label(_tr("来源:标题首页", "Caller: Title"), _scaled(12), Color("#e8d39d"))) var workspace := HBoxContainer.new() workspace.name = "SettingsWorkspace" workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL workspace.add_theme_constant_override("separation", 14) outer.add_child(workspace) var rail: Container if short_layout: rail = GridContainer.new() rail.columns = 3 rail.name = "SettingsCategories" rail.add_theme_constant_override("h_separation", 5) rail.add_theme_constant_override("v_separation", 4) outer.move_child(workspace, outer.get_child_count() - 1) outer.add_child(rail) outer.move_child(rail, workspace.get_index()) else: rail = VBoxContainer.new() rail.name = "SettingsCategories" rail.custom_minimum_size = Vector2(174, 0) rail.add_theme_constant_override("separation", 4) workspace.add_child(rail) for row in [ ["display", _tr("显示与文字", "Display & Text")], ["motion", _tr("动态与省电", "Motion & Power")], ["audio", _tr("音频与字幕", "Audio & Captions")], ["controls", _tr("控制", "Controls")], ["language", _tr("语言", "Language")], ["credits", _tr("制作人员与数据", "Credits & Data")], ]: var button := _tool_button("SettingsCategory_%s" % row[0], row[1], open_settings_category.bind(row[0])) button.custom_minimum_size = Vector2(0 if short_layout else 174, 38 if short_layout else 44) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL var selected: bool = settings_category == String(row[0]) button.set_meta("selected_entry_tab", selected) _set_entry_button_disabled(button, selected, selected) rail.add_child(button) var scroll := ScrollContainer.new() scroll.name = "SettingsScroll" scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL workspace.add_child(scroll) var content := VBoxContainer.new() content.name = "SettingsContent" content.size_flags_horizontal = Control.SIZE_EXPAND_FILL content.add_theme_constant_override("separation", 6) scroll.add_child(content) _build_settings_category(content) var footer := HBoxContainer.new() footer.name = "SettingsFooter" footer.add_theme_constant_override("separation", 8) outer.add_child(footer) var notice := _label(settings_notice, _scaled(12 if short_layout else 13), Color("#f0ca75")) notice.name = "SettingsNotice" notice.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(notice) var back := _tool_button("SettingsBack", _tr("返回", "Back"), request_settings_back) back.custom_minimum_size = Vector2(UITokens.command_width("secondary"), 44 if short_layout else 48) footer.add_child(back) var discard := _tool_button("SettingsDiscard", _tr("放弃并返回", "Discard & Back"), discard_settings) discard.custom_minimum_size = Vector2(UITokens.command_width("secondary"), 44 if short_layout else 48) footer.add_child(discard) var apply := _action_button("SettingsApply", _tr("应用", "Apply"), apply_settings) apply.custom_minimum_size = Vector2(UITokens.command_width("primary"), 44 if short_layout else 52) footer.add_child(apply) func _build_settings_category(content: VBoxContainer) -> void: match settings_category: "display": content.add_child(_settings_heading(_tr("显示与文字", "DISPLAY & TEXT"))) content.add_child(_choice_button("SettingSafeArea", _tr("安全区域", "Safe area"), "%d px" % int(settings_draft.safe_area), cycle_setting_safe_area)) content.add_child(_choice_button("SettingTextScale", _tr("文字大小", "Text size"), "%d%%" % int(round(float(settings_draft.text_scale) * 100.0)), cycle_setting_text_scale)) content.add_child(_slider_row("SettingBrightness", _tr("亮度", "Brightness"), float(settings_draft.brightness), 0.7, 1.2, 0.1, set_brightness)) content.add_child(_check_button("SettingHighContrast", _tr("高对比轮廓", "High-contrast contours"), bool(settings_draft.high_contrast), set_high_contrast)) content.add_child(_check_button("SettingColorCues", _tr("颜色以外的形状提示", "Shape cues beyond color"), bool(settings_draft.color_cues), set_color_cues)) "motion": content.add_child(_settings_heading(_tr("动态、闪烁与省电", "MOTION, FLASHES & POWER"))) content.add_child(_check_button("SettingReducedMotion", _tr("减少动画", "Reduced motion"), bool(settings_draft.reduced_motion), set_reduced_motion)) content.add_child(_check_button("SettingReducedFlashes", _tr("减少闪烁", "Reduced flashes"), bool(settings_draft.reduced_flashes), set_reduced_flashes)) content.add_child(_slider_row("SettingShake", _tr("镜头震动", "Camera shake"), float(settings_draft.shake_intensity), 0.0, 1.0, 0.25, set_shake)) content.add_child(_check_button("SettingHitPause", _tr("命中停顿", "Hit pause"), bool(settings_draft.hit_pause), set_hit_pause)) content.add_child(_choice_button("SettingParticles", _tr("粒子密度", "Particles"), _tr("低", "Low") if settings_draft.particle_density == "low" else _tr("标准", "Standard"), cycle_particles)) content.add_child(_check_button("SettingLowPower", _tr("低功耗模式", "Low-power mode"), bool(settings_draft.low_power), set_low_power)) "audio": content.add_child(_settings_heading(_tr("音频与字幕", "AUDIO & CAPTIONS"))) content.add_child(_check_button("SettingMasterMute", _tr("主静音", "Master mute"), bool(settings_draft.master_muted), set_master_mute)) content.add_child(_slider_row("SettingMusic", _tr("音乐", "Music"), float(settings_draft.music_volume), 0.0, 1.0, 0.1, set_music_volume)) content.add_child(_slider_row("SettingSFX", _tr("音效", "SFX"), float(settings_draft.sfx_volume), 0.0, 1.0, 0.1, set_sfx_volume)) content.add_child(_unavailable_row("SettingAmbienceUnavailable", _tr("环境音:DEV-1 未集成独立总线", "Ambience: no separate DEV-1 bus is integrated"))) content.add_child(_unavailable_row("SettingVoiceUnavailable", _tr("语音:当前没有语音资产", "Voice: no voice assets are present"))) content.add_child(_check_button("SettingCaptions", _tr("字幕", "Captions"), bool(settings_draft.captions), set_captions)) content.add_child(_choice_button("SettingCaptionDetail", _tr("字幕细节", "Caption detail"), _tr("完整", "Full") if settings_draft.caption_detail == "full" else _tr("关键事件", "Critical"), cycle_caption_detail)) content.add_child(_choice_button("SettingDynamicRange", _tr("动态范围", "Dynamic range"), String(settings_draft.dynamic_range).capitalize(), cycle_dynamic_range)) "controls": content.add_child(_settings_heading(_tr("控制", "CONTROLS"))) content.add_child(_label(_tr("触控、键鼠与控制器共享确认、取消和导航语义。", "Touch, keyboard/mouse and controller share confirm, cancel and navigation semantics."), _scaled(15), Color("#aebbc2"))) content.add_child(_slider_row("SettingHoldDuration", _tr("长按确认时间", "Hold duration"), float(settings_draft.hold_duration), 0.3, 1.5, 0.1, set_hold_duration)) content.add_child(_check_button("SettingVibration", _tr("震动", "Vibration"), bool(settings_draft.vibration), set_vibration)) content.add_child(_unavailable_row("SettingRemapUnavailable", _tr("按键重映射:DEV-1 仅提供默认入口映射;完整冲突检查属于后续控制页面", "Remapping: DEV-1 uses the default entry map; conflict-safe remapping belongs to the later Controls page"))) "language": content.add_child(_settings_heading(_tr("语言与地区格式", "LANGUAGE & LOCALE"))) content.add_child(_choice_button("SettingLocale", _tr("界面语言", "Interface language"), _tr("简体中文", "English"), cycle_setting_locale)) content.add_child(_label(_tr("日期 2026-08-17 · 数字 1,234.5 · 文本可换行", "Date 2026-08-17 · Number 1,234.5 · Text may wrap"), _scaled(16), Color("#f5ead0"))) "credits": content.add_child(_settings_heading(_tr("制作人员、许可与数据", "CREDITS, LICENSES & DATA"))) var credits := _label(_credits_text(), _scaled(14), Color("#f5ead0")) credits.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART credits.size_flags_horizontal = Control.SIZE_EXPAND_FILL content.add_child(credits) func open_settings_category(category: String) -> void: settings_category = category show_settings() func request_settings_back() -> void: if settings_draft != settings: settings_notice = _tr("有未保存的更改。请选择应用或放弃并返回。", "Unsaved changes. Choose Apply or Discard & Back.") show_settings() else: _return_from_settings() func discard_settings() -> void: settings_draft = {} settings_notice = "" _return_from_settings() func _return_from_settings() -> void: var context = _close_settings_context() var caller := settings_caller if context != null and not String(context.caller_id).is_empty(): caller = String(context.caller_id) if caller == "guild_handoff": show_guild_handoff(guild_company) elif caller == "readiness": show_readiness_review(guild_company) elif caller == "risk_board": show_risk_board(guild_company) elif caller == "risk_commitment": show_risk_commitment(guild_company) elif caller == "market": show_market(guild_company) elif caller == "recruit_review": show_recruit_review(market_selected_offer_id) elif caller == "battle_opening": show_battle_opening(guild_company, false) elif caller == "battle_live": _battle_live_return_from_settings() elif caller == "battle_pause_controls": _battle_live_return_from_settings() else: back_to_title() if context != null: call_deferred("_restore_settings_focus", String(context.return_focus_path)) func apply_settings() -> void: var saved: Dictionary = settings_service.save_settings(settings_draft) if not saved.ok: settings_notice = _tr("设置保存失败,请重试。", "Settings save failed. Retry.") + " [%s]" % String(saved.code) show_settings() return settings = saved.settings.duplicate(true) settings_draft = settings.duplicate(true) _apply_runtime_settings() settings_notice = _tr("设置已应用", "Settings applied") show_settings() func show_credits() -> void: settings_draft = settings.duplicate(true) settings_category = "credits" show_settings("credits") func _credits_text() -> String: return _tr( "Aetherbound Guild · 原创游戏与运行实现\n字体:Noto Sans SC,SIL Open Font License 1.1(完整许可随游戏提供)\n数据:核心游戏完全离线;DEV-1 不收集分析数据,不创建账户,不打开外部链接。\n隐私:设置与远征记录只保存在本地。", "Aetherbound Guild · original game and runtime implementation\nFont: Noto Sans SC, SIL Open Font License 1.1 (full license included)\nData: core play is fully offline; DEV-1 collects no analytics, creates no account and opens no external links.\nPrivacy: settings and expedition records remain local." ) func _apply_runtime_settings() -> void: var master := AudioServer.get_bus_index(&"Master") var music := AudioServer.get_bus_index(&"Music") var sfx := AudioServer.get_bus_index(&"SFX") if master >= 0: AudioServer.set_bus_mute(master, bool(settings.master_muted)) if music >= 0: AudioServer.set_bus_volume_db(music, linear_to_db(maxf(0.001, float(settings.music_volume)))) if sfx >= 0: AudioServer.set_bus_volume_db(sfx, linear_to_db(maxf(0.001, float(settings.sfx_volume)))) func cycle_setting_safe_area() -> void: settings_draft.safe_area = _next_value(SettingsServiceScript.SAFE_AREA_PRESETS, int(settings_draft.safe_area)) show_settings() func cycle_setting_text_scale() -> void: settings_draft.text_scale = _next_float(SettingsServiceScript.TEXT_SCALES, float(settings_draft.text_scale)) show_settings() func cycle_setting_locale() -> void: settings_draft.locale = "en" if String(settings_draft.locale) == "zh_CN" else "zh_CN" setup_draft = settings_draft.duplicate(true) show_settings() func cycle_particles() -> void: settings_draft.particle_density = "low" if settings_draft.particle_density == "standard" else "standard" show_settings() func cycle_caption_detail() -> void: settings_draft.caption_detail = "full" if settings_draft.caption_detail == "critical" else "critical" show_settings() func cycle_dynamic_range() -> void: settings_draft.dynamic_range = _next_value(["wide", "standard", "night"], String(settings_draft.dynamic_range)) show_settings() func set_brightness(value: float) -> void: settings_draft.brightness = value func set_high_contrast(value: bool) -> void: settings_draft.high_contrast = value func set_color_cues(value: bool) -> void: settings_draft.color_cues = value func set_reduced_motion(value: bool) -> void: settings_draft.reduced_motion = value func set_reduced_flashes(value: bool) -> void: settings_draft.reduced_flashes = value func set_shake(value: float) -> void: settings_draft.shake_intensity = value func set_hit_pause(value: bool) -> void: settings_draft.hit_pause = value func set_low_power(value: bool) -> void: settings_draft.low_power = value func set_master_mute(value: bool) -> void: settings_draft.master_muted = value func set_music_volume(value: float) -> void: settings_draft.music_volume = value func set_sfx_volume(value: float) -> void: settings_draft.sfx_volume = value func set_captions(value: bool) -> void: settings_draft.captions = value func set_hold_duration(value: float) -> void: settings_draft.hold_duration = value func set_vibration(value: bool) -> void: settings_draft.vibration = value func _next_value(options: Array, current: Variant) -> Variant: return options[(options.find(current) + 1) % options.size()] func _next_float(options: Array, current: float) -> float: for index in range(options.size()): if is_equal_approx(float(options[index]), current): return float(options[(index + 1) % options.size()]) return float(options[0]) func _settings_heading(text_value: String) -> Label: return _label(text_value, _scaled(19), Color("#e8d39d")) func _slider_row(node_name: String, text_value: String, value: float, minimum: float, maximum: float, step: float, callback: Callable) -> HBoxContainer: var row := HBoxContainer.new() row.name = node_name row.custom_minimum_size.y = 42 var label := _label("%s %.1f" % [text_value, value], _scaled(14), Color("#f5ead0")) label.custom_minimum_size.x = 190 row.add_child(label) var slider := HSlider.new() slider.name = "Slider" slider.min_value = minimum slider.max_value = maximum slider.step = step slider.value = value slider.custom_minimum_size.y = 24 slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL slider.add_theme_stylebox_override("slider", _flat_style(Color("#0d151d"), Color("#866f47"), 1, 3)) slider.add_theme_stylebox_override("grabber_area", _flat_style(Color("#73b9e7"), Color("#ead49b"), 1, 3)) slider.add_theme_stylebox_override("grabber_area_highlight", _flat_style(Color("#a9d9f2"), Color("#f5ead0"), 1, 3)) slider.value_changed.connect(callback) row.add_child(slider) return row func _unavailable_row(node_name: String, reason: String) -> Button: var button := _choice_button(node_name, reason, _tr("不可用", "Unavailable"), show_unavailable_reason.bind(reason)) _set_entry_button_disabled(button, true) button.tooltip_text = reason return button func show_unavailable_reason(reason: String) -> void: settings_notice = reason show_settings() func _show_child_placeholder(screen_id: String, heading: String, body: String) -> void: current_screen = screen_id _build_world_base() var safe := MarginContainer.new() safe.name = "ChildSafeArea" safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) safe.add_theme_constant_override("margin_left", 56) safe.add_theme_constant_override("margin_right", 56) safe.add_theme_constant_override("margin_top", 40) safe.add_theme_constant_override("margin_bottom", 40) add_child(safe) safe.add_child(_entry_chrome("ChildFrame", "settings_frame")) var box := VBoxContainer.new() box.name = "ChildLayout" box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 14) safe.add_child(box) box.add_child(_label(heading, _scaled(28), Color("#f5ead0"))) var explanation := _label(body, _scaled(17), Color("#aebbc2")) explanation.name = "ChildExplanation" explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART box.add_child(explanation) box.add_child(_action_button("ChildBack", _tr("返回标题首页", "Back to Title"), back_to_title)) func back_to_title() -> void: title_notice = "" settings_caller = "title" show_title() func request_exit() -> void: current_screen = "exit_review" _show_child_placeholder("exit_review", _tr("退出游戏?", "EXIT GAME?"), _tr("不会改变设置或本地远征记录。", "Settings and the local expedition will remain unchanged.")) var box := get_node("ChildSafeArea/ChildLayout") _set_entry_button_text(box.get_node("ChildBack"), _tr("取消", "Cancel")) box.add_child(_action_button("ExitConfirm", _tr("退出", "Exit"), confirm_exit)) func confirm_exit() -> void: get_tree().quit() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() if current_screen == "title": request_exit() elif current_screen == "initial_company" and initial_company_page != null: initial_company_page.request_back() elif current_screen == "guild_handoff": back_to_title() elif current_screen == "roster": back_to_guild() elif current_screen == "recruit_detail": show_roster(guild_company) elif current_screen == "dismissal_review": cancel_dismissal_review() elif current_screen == "contract_return_review": cancel_contract_return() elif current_screen in ["contract_choice", "market"]: back_to_guild() elif current_screen == "equipment_review": cancel_equipment_review() elif current_screen == "party_line": cancel_party_line() elif current_screen == "readiness_review": revise_readiness() elif current_screen == "risk_board": back_to_readiness() elif current_screen == "risk_commitment": cancel_risk_commitment() elif current_screen == "battle": if battle_opening_mode: review_battle_opening_state() else: toggle_battle_pause() elif current_screen == "battle_inspect": _battle_live_cancel_handoff() elif current_screen == "battle_pause_controls": _battle_live_cancel_handoff() elif current_screen == "battle_result": get_viewport().set_input_as_handled() show_battle_result(guild_company) elif current_screen == "battle_knockout_review": get_viewport().set_input_as_handled() show_battle_result(guild_company) elif current_screen == "battle_recovery": get_viewport().set_input_as_handled() show_battle_knockout_review(guild_company) elif current_screen == "recruit_review": cancel_recruit_review() elif current_screen in ["settings", "credits", "exit_review"]: _return_from_settings() func _save_status() -> String: var company := initial_company_service.load_company() if company.ok: return "valid" if String(company.code) != "COMPANY_MISSING": return "unsafe" var save_path := BootService.DEFAULT_SAVE_PATH if not FileAccess.file_exists(save_path): return "missing" var parser := JSON.new() if parser.parse(FileAccess.get_file_as_string(save_path)) != OK or not parser.data is Dictionary: return "unsafe" return "valid" if parser.data.has("save_kind") and parser.data.has("save_version") else "unsafe" func _build_world_base(content_scene := false) -> void: for child in get_children(): child.free() var background := TextureRect.new() background.name = "WorldBackground" background.texture = CONTENT_BACKGROUND if content_scene else GUILD_BACKGROUND background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(background) var shade := ColorRect.new() shade.color = Color(0.03, 0.06, 0.08, 0.12 if content_scene else 0.34) shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) shade.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(shade) func _entry_chrome(node_name: String, variant: String, icon := "") -> Control: var chrome = EntryChrome.new().configure(variant, icon) chrome.name = node_name chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) return chrome func _title_button(node_name: String, text_value: String, callback: Callable, icon: String, variant: String) -> Button: var button := Button.new() button.name = node_name button.flat = true button.clip_contents = false button.tooltip_text = text_value button.add_theme_font_override("font", UI_FONT) for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) button.add_child(_entry_chrome("Chrome", variant, icon)) var label := Label.new() label.name = "ButtonLabel" label.text = text_value label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.offset_left = 49.0 label.offset_right = -9.0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.add_theme_font_override("font", UI_FONT) label.add_theme_font_size_override("font_size", _scaled(14 if variant == "utility" else 17)) label.add_theme_color_override("font_color", Color("#202a31") if variant == "utility" else Color("#f5ead0")) button.add_child(label) button.set_meta("entry_chrome_variant", variant) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _set_title_button_disabled(button: Button, disabled: bool) -> void: button.disabled = disabled var label := button.get_node_or_null("ButtonLabel") as Label if label != null: var utility := String(button.get_meta("entry_chrome_variant", "")) == "utility" label.add_theme_color_override("font_color", Color("#626970") if utility else Color("#89939a") if disabled else Color("#f5ead0")) func _title_master_button(node_name: String, title: String, detail: String, callback: Callable, rect: Rect2, kind: String) -> Button: var button := Button.new() button.name = node_name button.flat = true button.clip_contents = true button.focus_mode = Control.FOCUS_ALL button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.tooltip_text = title _set_normalized_rect(button, rect) button.set_meta("title_master_kind", kind) var semantic_variant := "primary" if kind == "primary" else "secondary" if kind == "secondary" else "utility" var icon_kind := "" match node_name: "TitleNewGame": icon_kind = "confirm" "TitleContinue": icon_kind = "inspect" "TitleSettings": icon_kind = "settings" "TitleCredits": icon_kind = "help" var surface := Panel.new() surface.name = "ButtonSurface" surface.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) surface.mouse_filter = Control.MOUSE_FILTER_IGNORE surface.add_theme_stylebox_override("panel", UITokens.flat_command_style(semantic_variant)) button.add_child(surface) var label := _label(title, _scaled(15 if kind == "primary" else 12), Color("#263a43")) label.name = "ButtonLabel" _set_normalized_rect(label, Rect2(0.22 if not icon_kind.is_empty() else 0.06, 0.16 if kind != "utility" else 0.08, 0.72 if not icon_kind.is_empty() else 0.88, 0.46 if kind != "utility" else 0.84)) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(label) var detail_label := _label(detail, _scaled(7), Color("#6a4b2d")) detail_label.name = "DetailLabel" _set_normalized_rect(detail_label, Rect2(0.08, 0.52, 0.84, 0.24)) detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER detail_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER detail_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS detail_label.mouse_filter = Control.MOUSE_FILTER_IGNORE detail_label.visible = kind != "utility" and not detail.is_empty() button.add_child(detail_label) UIFactory.apply_flat_command(button, semantic_variant, "generic", false, false, "company") if not icon_kind.is_empty(): UIFactory.attach_icon(button, icon_kind, "CommandIcon", "dark", Rect2(0.04, 0.15, 0.19, 0.70)) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _set_title_master_button_disabled(button: Button, disabled: bool) -> void: button.disabled = disabled button.mouse_default_cursor_shape = Control.CURSOR_FORBIDDEN if disabled else Control.CURSOR_POINTING_HAND var kind := String(button.get_meta("title_master_kind", "secondary")) var semantic_variant := "primary" if kind == "primary" else "secondary" if kind == "secondary" else "utility" var surface := button.get_node_or_null("ButtonSurface") as Panel if surface != null: surface.add_theme_stylebox_override("panel", UITokens.flat_command_style(semantic_variant, "normal", false, disabled)) var title := button.get_node_or_null("ButtonLabel") as Label var detail := button.get_node_or_null("DetailLabel") as Label if title != null: title.add_theme_color_override("font_color", Color("#667078") if disabled else Color("#263a43")) if detail != null: detail.add_theme_color_override("font_color", Color("#77736c") if disabled else Color("#6a4b2d")) func _choice_button(node_name: String, label_text: String, value_text: String, callback: Callable) -> Button: var button := _entry_button_base(node_name, callback, "choice") button.tooltip_text = "%s: %s" % [label_text, value_text] button.custom_minimum_size = Vector2(0, 48) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL var label := _button_label("ChoiceLabel", label_text, _scaled(15), Color("#f5ead0")) label.anchor_right = 0.58 label.offset_left = 14.0 label.offset_right = -6.0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT button.add_child(label) var value := _button_label("ValueLabel", value_text, _scaled(14), Color("#202a31")) value.anchor_left = 0.58 value.offset_left = 6.0 value.offset_right = -10.0 button.add_child(value) return button func _action_button(node_name: String, text_value: String, callback: Callable) -> Button: var button := _entry_button_base(node_name, callback, "action_primary") button.tooltip_text = text_value button.custom_minimum_size = Vector2(UITokens.command_width("primary"), UITokens.command_height("primary")) var label := _button_label("ButtonLabel", text_value, _scaled(16), Color("#f5ead0")) label.offset_left = 46.0 label.offset_right = -10.0 button.add_child(label) return button func _tool_button(node_name: String, text_value: String, callback: Callable) -> Button: var button := _entry_button_base(node_name, callback, "tool") button.tooltip_text = text_value button.custom_minimum_size = Vector2(UITokens.command_width("secondary"), UITokens.command_height("secondary")) var label := _button_label("ButtonLabel", text_value, _scaled(14), Color("#f5ead0")) label.offset_left = 16.0 label.offset_right = -10.0 button.add_child(label) return button func _check_button(node_name: String, text_value: String, enabled: bool, callback: Callable) -> CheckButton: var check := CheckButton.new() check.name = node_name check.button_pressed = enabled check.custom_minimum_size = Vector2(0, 48) check.size_flags_horizontal = Control.SIZE_EXPAND_FILL check.flat = true for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: check.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) check.add_child(_entry_chrome("Chrome", "toggle")) var label := _button_label("ButtonLabel", text_value, _scaled(14), Color("#f5ead0")) label.offset_left = 14.0 label.offset_right = -52.0 label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART check.add_child(label) check.toggled.connect(callback) return check func _entry_button_base(node_name: String, callback: Callable, variant: String) -> Button: var button := Button.new() button.name = node_name button.flat = true for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]: button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) button.add_child(_entry_chrome("Chrome", variant)) button.set_meta("entry_chrome_variant", variant) button.pressed.connect(callback, CONNECT_DEFERRED) return button func _button_label(node_name: String, text_value: String, font_size: int, color: Color) -> Label: var label := _label(text_value, font_size, color) label.name = node_name label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE return label func _set_entry_button_disabled(button: Button, disabled: bool, selected := false) -> void: button.disabled = disabled for child_name in ["ButtonLabel", "ChoiceLabel", "ValueLabel"]: var label := button.get_node_or_null(child_name) as Label if label == null: continue if selected: label.add_theme_color_override("font_color", Color("#202a31")) elif disabled: label.add_theme_color_override("font_color", Color("#4f565b") if child_name == "ValueLabel" else Color("#89939a")) elif child_name == "ValueLabel": label.add_theme_color_override("font_color", Color("#202a31")) else: label.add_theme_color_override("font_color", Color("#f5ead0")) func _set_entry_button_text(button: Button, text_value: String) -> void: var label := button.get_node_or_null("ButtonLabel") as Label if label != null: label.text = text_value button.tooltip_text = text_value func _flat_style(background: Color, border: Color, width: int, radius: int) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = background style.border_color = border style.set_border_width_all(width) style.set_corner_radius_all(radius) return style func _label(text_value: String, font_size: int, color: Color) -> Label: var label := Label.new() label.text = text_value label.add_theme_font_override("font", UI_FONT) label.add_theme_font_size_override("font_size", font_size) label.add_theme_color_override("font_color", color) return label func _tr(zh: String, en: String) -> String: var locale := String(setup_draft.get("locale", settings.get("locale", "zh_CN"))) return zh if locale == "zh_CN" else en func _guild_text_size(design_size: int) -> int: var canvas_height := guild_master_design_canvas.size.y if is_instance_valid(guild_master_design_canvas) else size.y var height_ratio := clampf(canvas_height / 360.0, 1.0, 2.0) var canvas_scale := lerpf(1.0, height_ratio, 0.35) var text_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))) return maxi(7, int(round(float(design_size) * canvas_scale * text_scale))) func _scaled(value: int) -> int: var scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))) return int(round(float(value) * scale)) func _canonical_value(value): if value is float and is_equal_approx(value, round(value)): return int(round(value)) if value is Dictionary: var result := {} var keys: Array = value.keys() keys.sort_custom(func(left, right): return String(left) < String(right)) for key in keys: result[key] = _canonical_value(value[key]) return result if value is Array: var result: Array = [] for item in value: result.append(_canonical_value(item)) return result return value