Files
aetherbound-guild/runtime/entry/entry_shell.gd
T

7925 lines
379 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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