7925 lines
379 KiB
GDScript
7925 lines
379 KiB
GDScript
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 PageChrome = preload("res://ui/shared/page_chrome.gd")
|
||
const EntryUIIcon = preload("res://ui/shared/ui_icon.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 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: Control
|
||
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: Control
|
||
var boot_verification_rail: Control
|
||
var boot_status_seals: Array[Control] = []
|
||
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: Control
|
||
var first_launch_preview_image: TextureRect
|
||
var first_launch_preview_title_plate: Control
|
||
var first_launch_preview_detail_plate: Control
|
||
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 = Control.new()
|
||
boot_identity_plaque.name = "BootIdentityPlaque"
|
||
boot_identity_plaque.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_responsive_layout.add_child(boot_identity_plaque)
|
||
var identity_chrome := PageChrome.new().configure("header_band")
|
||
identity_chrome.name = "BootIdentityChrome"
|
||
identity_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
identity_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_identity_plaque.add_child(identity_chrome)
|
||
|
||
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", UITokens.font_size("heading", _entry_text_scale()))
|
||
identity.add_theme_color_override("font_color", PageChrome.INK)
|
||
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)
|
||
|
||
# The verification rail is now the shared parchment card; the entry-family
|
||
# rail asset is retired with the M63 migration.
|
||
boot_verification_rail = Control.new()
|
||
boot_verification_rail.name = "BootVerificationRail"
|
||
boot_verification_rail.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_responsive_layout.add_child(boot_verification_rail)
|
||
var rail_chrome := PageChrome.new().configure("card")
|
||
rail_chrome.name = "BootRailChrome"
|
||
rail_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
rail_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(rail_chrome)
|
||
|
||
for index in range(3):
|
||
var seal := Control.new()
|
||
seal.name = "BootStatusSeal%d" % index
|
||
seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(seal)
|
||
var pip := PageChrome.new().configure("pip", {"shape": "shield", "filled": false})
|
||
pip.name = "BootStatusPip%d" % index
|
||
pip.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
seal.add_child(pip)
|
||
boot_status_seals.append(seal)
|
||
var mark := _label(str(index + 1), UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK)
|
||
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", UITokens.font_size("body", _entry_text_scale()))
|
||
status_label.add_theme_color_override("font_color", PageChrome.INK)
|
||
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", UITokens.font_size("micro", _entry_text_scale()))
|
||
detail_label.add_theme_color_override("font_color", PageChrome.INK_SOFT)
|
||
detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
detail_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(detail_label)
|
||
|
||
# Shared progress material (M63): track and fill come from page_chrome, the
|
||
# value stays engine data.
|
||
progress_bar = PageChrome.new().configure("progress", {"value": 0.0})
|
||
progress_bar.name = "BootProgress"
|
||
boot_verification_rail.add_child(progress_bar)
|
||
|
||
boot_progress_value = _label("0%", UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK)
|
||
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", UITokens.font_size("micro", _entry_text_scale()))
|
||
diagnostics_label.add_theme_color_override("font_color", UITokens.COLOR_DANGER)
|
||
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:
|
||
# Shared command plate + engine label/icon (M63). The button keeps its node
|
||
# name, 48px minimum target and .pressed callback; visible copy moves to the
|
||
# child ButtonLabel like every other migrated command.
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.custom_minimum_size = Vector2(72.0, 48.0)
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.set_meta("ui_factory_control", true)
|
||
button.set_meta("ui_semantic_variant", "secondary")
|
||
button.set_meta("ui_shape", "dock_plate")
|
||
button.set_meta("ui_family", "company")
|
||
for style_name in ["normal", "hover", "pressed", "focus", "disabled", "hover_pressed"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
var plate := PageChrome.new().configure("command_plate", {"state": "secondary"})
|
||
plate.name = "CommandPlate"
|
||
plate.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(plate)
|
||
var icon_kind := "retry" if node_name == "BootRetry" else "help" if node_name == "BootDiagnosticsAction" else "close"
|
||
var icon := EntryUIIcon.new().configure(icon_kind, "light")
|
||
icon.name = "CommandIcon"
|
||
icon.anchor_left = 0.08
|
||
icon.anchor_right = 0.26
|
||
icon.anchor_top = 0.24
|
||
icon.anchor_bottom = 0.76
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(icon)
|
||
var label := _label(text_value, UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK)
|
||
label.name = "ButtonLabel"
|
||
label.anchor_left = 0.28
|
||
label.anchor_right = 0.96
|
||
label.anchor_top = 0.0
|
||
label.anchor_bottom = 1.0
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.clip_text = true
|
||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(label)
|
||
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")
|
||
# M63: visible copy lives in the shared ButtonLabel child.
|
||
_set_boot_action_text(retry_button, _tr("重试", "Retry"))
|
||
_set_boot_action_text(boot_diagnostics_button, _tr("诊断", "Diagnostics"))
|
||
_set_boot_action_text(boot_exit_button, _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))
|
||
if progress_bar.has_method("set_progress"):
|
||
progress_bar.set_progress(float(completed) / maxf(1.0, float(total)))
|
||
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)
|
||
# The pip is pale parchment now, so the step mark is ink, not ivory.
|
||
boot_status_marks[index].add_theme_color_override("font_color", PageChrome.INK if finished else PageChrome.INK_SOFT if active else PageChrome.INK_SOFT)
|
||
var pip := boot_status_seals[index].get_node_or_null("BootStatusPip%d" % index)
|
||
if pip != null and pip.has_method("configure"):
|
||
pip.configure("pip", {"shape": "shield", "filled": finished, "active": active})
|
||
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)
|
||
# M63-B: the entry-family preview frame gives way to the shared parchment
|
||
# card; the node stays a layout container for the miniature title preview.
|
||
first_launch_preview_frame = Control.new()
|
||
first_launch_preview_frame.name = "FirstLaunchPreviewFrame"
|
||
first_launch_preview_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_responsive_layout.add_child(first_launch_preview_frame)
|
||
var preview_chrome := PageChrome.new().configure("card")
|
||
preview_chrome.name = "PreviewFrameChrome"
|
||
preview_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
preview_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(preview_chrome)
|
||
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(UITokens.COLOR_IVORY, 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"), UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK)
|
||
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 = Control.new()
|
||
first_launch_preview_title_plate.name = "SetupPreviewTitlePlate"
|
||
first_launch_preview_title_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(first_launch_preview_title_plate)
|
||
var title_plate_chrome := PageChrome.new().configure("header_band")
|
||
title_plate_chrome.name = "TitlePlateChrome"
|
||
title_plate_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
title_plate_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_title_plate.add_child(title_plate_chrome)
|
||
var preview_title := _label(_tr("裂脉远征团", "AETHERBOUND GUILD"), UITokens.font_size("body", _entry_text_scale()), PageChrome.INK)
|
||
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 = Control.new()
|
||
first_launch_preview_detail_plate.name = "SetupPreviewDetailPlate"
|
||
first_launch_preview_detail_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(first_launch_preview_detail_plate)
|
||
var detail_plate_chrome := PageChrome.new().configure("card")
|
||
detail_plate_chrome.name = "DetailPlateChrome"
|
||
detail_plate_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
detail_plate_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_detail_plate.add_child(detail_plate_chrome)
|
||
var preview_detail := _label(_tr("安全区 · 字号 · 字幕", "Safe area · text · captions"), UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK_SOFT)
|
||
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)
|
||
# A full-height rolled card would squash the rows below the 48px touch
|
||
# contract, so the column keeps its original composition: heading and
|
||
# subheading on a shared header band, rows as shared command plates.
|
||
var controls_band := PageChrome.new().configure("header_band")
|
||
controls_band.name = "ControlsHeaderBand"
|
||
controls_band.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_settings_panel.add_child(controls_band)
|
||
var heading := _label(_tr("首次启动设置", "FIRST-LAUNCH SETUP"), UITokens.font_size("heading", _entry_text_scale()), PageChrome.INK)
|
||
heading.name = "FirstLaunchHeading"
|
||
first_launch_settings_panel.add_child(heading)
|
||
var subheading := _label(_tr("这些选项只影响显示与辅助功能。", "These choices affect presentation and accessibility only."), UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK_SOFT)
|
||
subheading.name = "FirstLaunchSubheading"
|
||
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, 58)
|
||
apply.focus_mode = Control.FOCUS_ALL
|
||
first_launch_apply = apply
|
||
first_launch_settings_panel.add_child(apply)
|
||
first_launch_error = _label("", UITokens.font_size("micro", _entry_text_scale()), UITokens.COLOR_DANGER)
|
||
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)
|
||
# Measured parchment opening: any content anchored above the card's rolled
|
||
# edge is painted under the roll (the M58-C lesson). Inset every child by the
|
||
# panel's edge fractions so text and art live on the face.
|
||
var preview_inset_x := preview_width * PageChrome.PANEL_EDGE_X + 6.0
|
||
var preview_inset_y := preview_height * PageChrome.PANEL_EDGE_Y + 6.0
|
||
first_launch_preview_image.position = Vector2(preview_inset_x, preview_inset_y)
|
||
first_launch_preview_image.size = Vector2(
|
||
preview_width - preview_inset_x * 2.0,
|
||
preview_height - preview_inset_y * 2.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(preview_inset_x, preview_inset_y * 0.42)
|
||
preview_heading.size = Vector2(preview_width - preview_inset_x * 2.0, maxf(20.0, preview_inset_y * 0.5))
|
||
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 - preview_inset_y - 44.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)
|
||
# Header band opening: the heading block sits on the band face.
|
||
var band_height := clampf(content_height * 0.19, 86.0, 132.0)
|
||
var band_inset_x := settings_width * PageChrome.PANEL_EDGE_X + 8.0
|
||
var band_inset_y := band_height * PageChrome.PANEL_EDGE_Y + 4.0
|
||
var controls_inner_width := settings_width - band_inset_x * 2.0
|
||
var controls_band := first_launch_settings_panel.get_node("ControlsHeaderBand") as Control
|
||
controls_band.position = Vector2(0.0, 0.0)
|
||
controls_band.size = Vector2(settings_width, band_height)
|
||
var heading := first_launch_settings_panel.get_node("FirstLaunchHeading") as Label
|
||
heading.position = Vector2(band_inset_x, band_inset_y)
|
||
heading.size = Vector2(controls_inner_width, maxf(28.0, band_height * 0.42))
|
||
var subheading := first_launch_settings_panel.get_node("FirstLaunchSubheading") as Label
|
||
subheading.position = Vector2(band_inset_x, heading.position.y + heading.size.y)
|
||
subheading.size = Vector2(controls_inner_width, maxf(22.0, band_height * 0.30))
|
||
var panel_inset_x := 0.0
|
||
var row_y := band_height + 8.0
|
||
var row_gap := 6.0 if available.y >= 420.0 else 4.0
|
||
var row_height := clampf((content_height - row_y - 58.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.0, row_y)
|
||
row.size = Vector2(settings_width, row_height)
|
||
_layout_first_setting_row(row)
|
||
row_y += row_height + row_gap
|
||
var apply_y := minf(row_y + 6.0, content_height - 58.0)
|
||
first_launch_apply.position = Vector2(0.0, apply_y)
|
||
first_launch_apply.size = Vector2(settings_width, 58.0)
|
||
# The failure line only appears on a rejected apply; keep it inside the
|
||
# column even when the compact rows fill the height.
|
||
first_launch_error.position = Vector2(0.0, minf(apply_y + 60.0, maxf(0.0, content_height - 19.0)))
|
||
first_launch_error.size = Vector2(settings_width, 18.0)
|
||
|
||
|
||
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())
|
||
# M63-B: rows are shared command plates; the badge is a shared pip and the
|
||
# current value sits on a small parchment chip (ValuePlate keeps its name).
|
||
var background := PageChrome.new().configure("command_plate", {"state": "secondary"})
|
||
background.name = "Background"
|
||
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(background)
|
||
var icon := Control.new()
|
||
icon.name = "Icon"
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(icon)
|
||
var icon_chrome := PageChrome.new().configure("pip", {"shape": "circle", "filled": false})
|
||
icon_chrome.name = "IconPip"
|
||
icon_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
icon_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
icon.add_child(icon_chrome)
|
||
var icon_label := _label(String(row_data.icon), UITokens.font_size("label", _entry_text_scale()), PageChrome.INK)
|
||
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])), UITokens.font_size("label", _entry_text_scale()), PageChrome.INK)
|
||
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 := Control.new()
|
||
value_plate.name = "ValuePlate"
|
||
value_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(value_plate)
|
||
var value_chrome := PageChrome.new().configure("card")
|
||
value_chrome.name = "ValueChrome"
|
||
value_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
value_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
value_plate.add_child(value_chrome)
|
||
var value_label := _label("", UITokens.font_size("micro", _entry_text_scale()), PageChrome.INK)
|
||
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 Control
|
||
var label := button.get_node("RowLabel") as Label
|
||
var value_plate := button.get_node("ValuePlate") as Control
|
||
# The narrow two-column phone rows cannot hold a full label next to a 68px
|
||
# value chip at label size, which silently trimmed row names. Compact rows
|
||
# shrink the badge/chip and let the label wrap to two micro lines so every
|
||
# shipped name stays complete.
|
||
var compact_row := row_size.x < 220.0
|
||
# Badge and value chip live on the plate face: inset by the shared plate's
|
||
# measured rolled edge so nothing is painted over the decorative border.
|
||
var plate_inset := row_size.x * PageChrome.PLATE_EDGE_X + 4.0
|
||
var icon_size := clampf(row_size.y - 14.0, 24.0 if compact_row else 30.0, 52.0)
|
||
var value_width := clampf(row_size.x * (0.26 if compact_row else 0.30), 46.0 if compact_row else 68.0, 132.0)
|
||
icon.position = Vector2(plate_inset, (row_size.y - icon_size) * 0.5)
|
||
icon.size = Vector2(icon_size, icon_size)
|
||
var value_height := maxf(28.0, row_size.y - 14.0)
|
||
value_plate.position = Vector2(row_size.x - value_width - plate_inset, (row_size.y - value_height) * 0.5)
|
||
value_plate.size = Vector2(value_width, value_height)
|
||
label.position = Vector2(icon.position.x + icon_size + 5.0, 0.0)
|
||
label.size = Vector2(maxf(24.0, value_plate.position.x - label.position.x - 4.0), row_size.y)
|
||
label.add_theme_font_size_override("font_size", UITokens.font_size("micro" if compact_row else "label", _entry_text_scale()))
|
||
if compact_row:
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.max_lines_visible = 2
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
else:
|
||
label.autowrap_mode = TextServer.AUTOWRAP_OFF
|
||
label.max_lines_visible = -1
|
||
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, 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())
|
||
# M63-B: the primary command uses the shared plate; the authored
|
||
# CommandFrame node name is kept for the existing focused contract.
|
||
var frame := PageChrome.new().configure("command_plate", {"state": "primary"})
|
||
frame.name = "CommandFrame"
|
||
frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(frame)
|
||
var label := _label(text_value, UITokens.font_size("label", _entry_text_scale()), PageChrome.INK)
|
||
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_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 _set_boot_action_text(button: Button, text_value: String) -> void:
|
||
var label := button.get_node_or_null("ButtonLabel") as Label
|
||
if label != null:
|
||
label.text = text_value
|
||
else:
|
||
button.text = text_value
|
||
|
||
|
||
func _entry_text_scale() -> float:
|
||
# Entry pages honour the same user text scale as every other page; the
|
||
# shared token layer turns it into role sizes.
|
||
return float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
|
||
|
||
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
|