8363 lines
424 KiB
GDScript
8363 lines
424 KiB
GDScript
class_name ABGEntryShell
|
||
extends Control
|
||
|
||
const BootService = preload("res://entry/boot_service.gd")
|
||
const SettingsStore = preload("res://entry/settings_store.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 GuildContractService = preload("res://entry/guild_contract_service.gd")
|
||
const FormationRiskService = preload("res://entry/formation_risk_service.gd")
|
||
const PartyLineTrack = preload("res://entry/party_line_track.gd")
|
||
const PartyLineDiagram = preload("res://entry/party_line_diagram.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 BOOT_BACKGROUND = preload("res://assets/entry/boot/background.png")
|
||
const BOOT_IDENTITY_PLAQUE = preload("res://assets/entry/boot/identity-plaque.png")
|
||
const BOOT_VERIFICATION_RAIL = preload("res://assets/entry/boot/verification-rail.png")
|
||
const BOOT_STATUS_SEAL = preload("res://assets/entry/boot/status-seal.png")
|
||
const BOOT_ACTION_TAB = preload("res://assets/entry/boot/action-tab.png")
|
||
const FIRST_LAUNCH_BACKGROUND = preload("res://assets/entry/first_launch/background.png")
|
||
const FIRST_LAUNCH_PREVIEW_FRAME = preload("res://assets/entry/first_launch/preview-frame.png")
|
||
const FIRST_LAUNCH_SETTING_ROW = preload("res://assets/entry/first_launch/setting-row.png")
|
||
const FIRST_LAUNCH_APPLY_COMMAND = preload("res://assets/entry/first_launch/apply-command.png")
|
||
const FIRST_LAUNCH_RETURN_TAB = preload("res://assets/entry/first_launch/return-tab.png")
|
||
const FIRST_LAUNCH_SETTING_ICON = preload("res://assets/entry/first_launch/setting-icon.png")
|
||
const FIRST_LAUNCH_VALUE_PLATE = preload("res://assets/entry/first_launch/value-plate.png")
|
||
const GUILD_MASTER_BACKGROUND = preload("res://assets/backgrounds/guild_master_b_background.png")
|
||
const GUILD_MASTER_COMPOSITE = preload("res://assets/backgrounds/guild_master_b_composite.png")
|
||
const GUILD_MASTER_BELLKEEPER = preload("res://assets/entry/guild_master_b_characters/company_bellkeeper.png")
|
||
const GUILD_MASTER_SCOUT = preload("res://assets/entry/guild_master_b_characters/company_scout.png")
|
||
const GUILD_MASTER_TOP_RAIL = preload("res://assets/entry/guild_master_b_ui/top_rail.png")
|
||
const GUILD_MASTER_BOTTOM_RAIL = preload("res://assets/entry/guild_master_b_ui/bottom_rail.png")
|
||
const GUILD_MASTER_KIOSK = preload("res://assets/entry/guild_master_b_ui/contract_kiosk.png")
|
||
const GUILD_MASTER_COMPASS = preload("res://assets/entry/guild_master_b_ui/route_compass.png")
|
||
const GUILD_MASTER_MEDALLION = preload("res://assets/entry/guild_master_b_ui/primary_medallion.png")
|
||
const GUILD_DESK_WORLD = preload("res://assets/entry/guild_desk_v001/world.png")
|
||
const GUILD_DESK_CONTRACT_BOARD = preload("res://assets/entry/guild_desk_v001/contract-board.png")
|
||
const GUILD_DESK_RESOURCE_BADGE = preload("res://assets/entry/guild_desk_v001/resource-badge.png")
|
||
const GUILD_DESK_TOP_LEFT = preload("res://assets/entry/guild_desk_v001/top_rail/left.png")
|
||
const GUILD_DESK_TOP_CENTER = preload("res://assets/entry/guild_desk_v001/top_rail/center.png")
|
||
const GUILD_DESK_TOP_RIGHT = preload("res://assets/entry/guild_desk_v001/top_rail/right.png")
|
||
const GUILD_DESK_TOP_PRESSURE = preload("res://assets/entry/guild_desk_v001/top_rail/pressure.png")
|
||
const GUILD_DESK_TOP_COIN = preload("res://assets/entry/guild_desk_v001/top_rail/coin.png")
|
||
const GUILD_DESK_TOP_COMPANY = preload("res://assets/entry/guild_desk_v001/top_rail/company.png")
|
||
const GUILD_DESK_BOTTOM_LEFT = preload("res://assets/entry/guild_desk_v001/bottom_rail/left.png")
|
||
const GUILD_DESK_BOTTOM_CENTER = preload("res://assets/entry/guild_desk_v001/bottom_rail/center.png")
|
||
const GUILD_DESK_BOTTOM_RIGHT = preload("res://assets/entry/guild_desk_v001/bottom_rail/right.png")
|
||
const GUILD_DESK_BOTTOM_ROSTER = preload("res://assets/entry/guild_desk_v001/bottom_rail/roster.png")
|
||
const GUILD_DESK_BOTTOM_EQUIPMENT = preload("res://assets/entry/guild_desk_v001/bottom_rail/equipment.png")
|
||
const GUILD_DESK_BOTTOM_PARTY = preload("res://assets/entry/guild_desk_v001/bottom_rail/party.png")
|
||
const PARTY_COMMAND_UNDO = preload("res://assets/entry/party_line_v001/undo.png")
|
||
const PARTY_COMMAND_RESET = preload("res://assets/entry/party_line_v001/reset.png")
|
||
const PARTY_COMMAND_SAVE = preload("res://assets/entry/party_line_v001/save-order.png")
|
||
const PARTY_COMMAND_BACK = preload("res://assets/entry/party_line_v001/back.png")
|
||
const PARTY_LINE_MASTER_PATH := "res://assets/entry/party_line_v001/party-line-master.png"
|
||
const READINESS_STATIC_MASTER_PATH := "res://assets/entry/pty_002_readiness/readiness-static-master.png"
|
||
const READINESS_COMMAND_REVISE = preload("res://assets/entry/pty_002_readiness/revise_party@3x.png")
|
||
const READINESS_COMMAND_BACK = preload("res://assets/entry/pty_002_readiness/back_to_guild@3x.png")
|
||
const READINESS_COMMAND_ACCEPT = preload("res://assets/entry/pty_002_readiness/accept_preparation@3x.png")
|
||
const READINESS_COMMAND_RISK = preload("res://assets/entry/pty_002_readiness/risk_next@3x.png")
|
||
const RISK_BOARD_STATIC_MASTER = preload("res://assets/entry/rsk_001_risk_board/risk-board-static-master.png")
|
||
const RISK_COMMITMENT_STATIC_MASTER = preload("res://assets/entry/rsk_002_risk_commitment/risk-commitment-static-master.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_store := SettingsStore.new()
|
||
var initial_company_service := InitialCompanyDraftService.new()
|
||
var guild_contract_service := GuildContractService.new()
|
||
var formation_risk_service := FormationRiskService.new()
|
||
var battle_service := BattleService.new()
|
||
var initial_company_page: Control
|
||
var settings: Dictionary = {}
|
||
var setup_draft: Dictionary = {}
|
||
var boot_snapshot: Dictionary = {}
|
||
var boot_generation := 0
|
||
var status_label: Label
|
||
var detail_label: Label
|
||
var progress_bar: ProgressBar
|
||
var retry_button: Button
|
||
var diagnostics_label: Label
|
||
var boot_diagnostics_button: Button
|
||
var boot_exit_button: Button
|
||
var boot_responsive_layout: Control
|
||
var boot_identity_plaque: TextureRect
|
||
var boot_verification_rail: NinePatchRect
|
||
var boot_status_seals: Array[TextureRect] = []
|
||
var boot_status_marks: Array[Label] = []
|
||
var boot_progress_value: Label
|
||
var boot_diagnostics_expanded := false
|
||
var first_launch_responsive_layout: Control
|
||
var first_launch_preview_frame: NinePatchRect
|
||
var first_launch_preview_image: TextureRect
|
||
var first_launch_preview_title_plate: NinePatchRect
|
||
var first_launch_preview_detail_plate: NinePatchRect
|
||
var first_launch_settings_panel: Control
|
||
var first_launch_apply: Button
|
||
var first_launch_error: Label
|
||
var first_launch_setting_buttons: Dictionary = {}
|
||
var first_launch_setting_values: Dictionary = {}
|
||
var current_screen := "boot"
|
||
var title_notice := ""
|
||
var settings_draft: Dictionary = {}
|
||
var settings_category := "display"
|
||
var settings_notice := ""
|
||
var settings_caller := "title"
|
||
var guild_company: Dictionary = {}
|
||
var 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 party_draft_order: Array[String] = []
|
||
var party_undo_order: Array[String] = []
|
||
var party_selected_recruit_id := ""
|
||
var party_preview: 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_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_opening_mode := false
|
||
var battle_opening_state := "ready_to_observe"
|
||
var battle_opening_notice := ""
|
||
var battle_opening_handoff_receipt: 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_live_rendering := false
|
||
var battle_live_render_requested := false
|
||
var title_master_design_canvas: Control
|
||
var guild_master_design_canvas: Control
|
||
var party_master_design_canvas: Control
|
||
var readiness_master_design_canvas: Control
|
||
var risk_board_master_design_canvas: Control
|
||
var risk_commitment_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(guild_master_design_canvas):
|
||
_layout_guild_master_canvas()
|
||
if what == NOTIFICATION_RESIZED and is_instance_valid(party_master_design_canvas):
|
||
_layout_party_master_canvas()
|
||
if what == NOTIFICATION_RESIZED and is_instance_valid(readiness_master_design_canvas):
|
||
_layout_readiness_master_canvas()
|
||
if what == NOTIFICATION_RESIZED and is_instance_valid(risk_board_master_design_canvas):
|
||
_layout_risk_board_master_canvas()
|
||
if what == NOTIFICATION_RESIZED and is_instance_valid(risk_commitment_master_design_canvas):
|
||
_layout_risk_commitment_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 == "battle" 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)
|
||
tree_exiting.connect(_invalidate_battle_playback)
|
||
_build_boot_screen()
|
||
if auto_start:
|
||
call_deferred("start_boot")
|
||
|
||
|
||
func start_boot() -> void:
|
||
boot_generation += 1
|
||
boot_diagnostics_expanded = false
|
||
var generation := boot_generation
|
||
boot_snapshot = boot_service.begin()
|
||
_render_boot_state()
|
||
while generation == boot_generation and String(boot_snapshot.state) == "running":
|
||
await get_tree().process_frame
|
||
boot_snapshot = boot_service.verify_next()
|
||
_render_boot_state()
|
||
if generation == boot_generation and String(boot_snapshot.state) == "complete":
|
||
await get_tree().create_timer(0.18).timeout
|
||
if generation == boot_generation:
|
||
boot_completed.emit()
|
||
_open_after_boot()
|
||
|
||
|
||
func retry_boot() -> void:
|
||
if String(boot_snapshot.get("state", "")) != "error":
|
||
return
|
||
start_boot()
|
||
|
||
|
||
func show_boot_diagnostics() -> void:
|
||
if String(boot_snapshot.get("state", "")) != "error":
|
||
return
|
||
boot_diagnostics_expanded = true
|
||
diagnostics_label.visible = true
|
||
_layout_boot_screen()
|
||
|
||
|
||
func exit_from_boot() -> void:
|
||
get_tree().quit()
|
||
|
||
|
||
func _build_boot_screen() -> void:
|
||
current_screen = "boot"
|
||
for child in get_children():
|
||
child.free()
|
||
boot_status_seals.clear()
|
||
boot_status_marks.clear()
|
||
|
||
var background := TextureRect.new()
|
||
background.name = "BootWorld"
|
||
background.texture = BOOT_BACKGROUND
|
||
background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||
background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(background)
|
||
|
||
var shade := ColorRect.new()
|
||
shade.name = "BootWorldShade"
|
||
shade.color = Color(0.035, 0.07, 0.10, 0.06)
|
||
shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(shade)
|
||
|
||
boot_responsive_layout = Control.new()
|
||
boot_responsive_layout.name = "BootResponsiveLayout"
|
||
boot_responsive_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
boot_responsive_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(boot_responsive_layout)
|
||
|
||
boot_identity_plaque = TextureRect.new()
|
||
boot_identity_plaque.name = "BootIdentityPlaque"
|
||
boot_identity_plaque.texture = BOOT_IDENTITY_PLAQUE
|
||
boot_identity_plaque.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
boot_identity_plaque.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
boot_identity_plaque.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
boot_identity_plaque.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_responsive_layout.add_child(boot_identity_plaque)
|
||
|
||
var identity := Label.new()
|
||
identity.name = "BootIdentity"
|
||
identity.text = _tr("裂脉远征团", "AETHERBOUND GUILD")
|
||
identity.add_theme_font_override("font", UI_FONT)
|
||
identity.add_theme_font_size_override("font_size", _scaled(18))
|
||
identity.add_theme_color_override("font_color", Color("#213744"))
|
||
identity.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
identity.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
identity.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_identity_plaque.add_child(identity)
|
||
|
||
boot_verification_rail = _boot_nine_patch("BootVerificationRail", BOOT_VERIFICATION_RAIL, 112, 0)
|
||
boot_responsive_layout.add_child(boot_verification_rail)
|
||
|
||
for index in range(3):
|
||
var seal := TextureRect.new()
|
||
seal.name = "BootStatusSeal%d" % index
|
||
seal.texture = BOOT_STATUS_SEAL
|
||
seal.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
seal.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
seal.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(seal)
|
||
boot_status_seals.append(seal)
|
||
var mark := _label(str(index + 1), _scaled(13), Color("#f5ead0"))
|
||
mark.name = "BootStatusMark%d" % index
|
||
mark.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
mark.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
mark.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
mark.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
seal.add_child(mark)
|
||
boot_status_marks.append(mark)
|
||
|
||
status_label = Label.new()
|
||
status_label.name = "BootStatus"
|
||
status_label.add_theme_font_override("font", UI_FONT)
|
||
status_label.add_theme_font_size_override("font_size", _scaled(15))
|
||
status_label.add_theme_color_override("font_color", Color("#213744"))
|
||
status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
status_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(status_label)
|
||
|
||
detail_label = Label.new()
|
||
detail_label.name = "BootDetail"
|
||
detail_label.add_theme_font_override("font", UI_FONT)
|
||
detail_label.add_theme_font_size_override("font_size", _scaled(10))
|
||
detail_label.add_theme_color_override("font_color", Color("#5d5a50"))
|
||
detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
detail_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(detail_label)
|
||
|
||
progress_bar = ProgressBar.new()
|
||
progress_bar.name = "BootProgress"
|
||
progress_bar.show_percentage = false
|
||
progress_bar.add_theme_stylebox_override("background", _flat_style(Color("#d7c6a6"), Color("#866f47"), 1, 5))
|
||
progress_bar.add_theme_stylebox_override("fill", _flat_style(Color("#5dc9e9"), Color("#ead49b"), 1, 5))
|
||
boot_verification_rail.add_child(progress_bar)
|
||
|
||
boot_progress_value = _label("0%", _scaled(9), Color("#213744"))
|
||
boot_progress_value.name = "BootProgressValue"
|
||
boot_progress_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
boot_progress_value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
boot_progress_value.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(boot_progress_value)
|
||
|
||
retry_button = _boot_action_button("BootRetry", _tr("重试", "Retry"), retry_boot)
|
||
retry_button.visible = false
|
||
boot_verification_rail.add_child(retry_button)
|
||
boot_diagnostics_button = _boot_action_button("BootDiagnosticsAction", _tr("诊断", "Diagnostics"), show_boot_diagnostics)
|
||
boot_diagnostics_button.visible = false
|
||
boot_verification_rail.add_child(boot_diagnostics_button)
|
||
boot_exit_button = _boot_action_button("BootExit", _tr("退出", "Exit"), exit_from_boot)
|
||
boot_exit_button.visible = false
|
||
boot_verification_rail.add_child(boot_exit_button)
|
||
|
||
diagnostics_label = Label.new()
|
||
diagnostics_label.name = "BootDiagnostics"
|
||
diagnostics_label.add_theme_font_override("font", UI_FONT)
|
||
diagnostics_label.add_theme_font_size_override("font_size", _scaled(9))
|
||
diagnostics_label.add_theme_color_override("font_color", Color("#914b32"))
|
||
diagnostics_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
diagnostics_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
boot_verification_rail.add_child(diagnostics_label)
|
||
_layout_boot_screen()
|
||
|
||
|
||
func _layout_boot_screen() -> void:
|
||
if not is_instance_valid(boot_responsive_layout) or not is_instance_valid(boot_verification_rail):
|
||
return
|
||
var available := size
|
||
if available.x <= 0.0 or available.y <= 0.0:
|
||
return
|
||
var safe_x := clampf(available.x * 0.04, 20.0, 48.0)
|
||
var safe_y := clampf(available.y * 0.06, 16.0, 40.0)
|
||
var plaque_width := clampf(available.x * 0.26, 180.0, 320.0)
|
||
var plaque_height := clampf(plaque_width / 1.88, 72.0, 150.0)
|
||
boot_identity_plaque.position = Vector2(safe_x, safe_y)
|
||
boot_identity_plaque.size = Vector2(plaque_width, plaque_height)
|
||
var identity := boot_identity_plaque.get_node_or_null("BootIdentity") as Label
|
||
if identity != null:
|
||
identity.position = Vector2(plaque_width * 0.14, plaque_height * 0.26)
|
||
identity.size = Vector2(plaque_width * 0.72, plaque_height * 0.48)
|
||
|
||
var rail_width := minf(available.x - safe_x * 2.0, minf(1120.0, available.x * 0.84))
|
||
var rail_height := clampf(available.y * 0.25, 96.0, 176.0)
|
||
boot_verification_rail.position = Vector2((available.x - rail_width) * 0.5, available.y - safe_y - rail_height)
|
||
boot_verification_rail.size = Vector2(rail_width, rail_height)
|
||
var padding := clampf(rail_height * 0.12, 10.0, 18.0)
|
||
var content_top := clampf(rail_height * 0.28, 27.0, 50.0)
|
||
var content_bottom := clampf(rail_height * 0.17, 16.0, 30.0)
|
||
var error_state := String(boot_snapshot.get("state", "running")) == "error"
|
||
var seal_size := clampf(rail_height * 0.42, 36.0, 64.0)
|
||
var seal_gap := clampf(seal_size * 0.12, 4.0, 8.0)
|
||
var seals_width := seal_size * 3.0 + seal_gap * 2.0
|
||
for index in range(boot_status_seals.size()):
|
||
var seal := boot_status_seals[index]
|
||
seal.visible = not error_state
|
||
seal.position = Vector2(padding + index * (seal_size + seal_gap), (rail_height - seal_size) * 0.5)
|
||
seal.size = Vector2(seal_size, seal_size)
|
||
|
||
if error_state:
|
||
var inner_left := clampf(rail_height * 0.9, 84.0, 120.0)
|
||
var inner_right := inner_left
|
||
var inner_width := rail_width - inner_left - inner_right
|
||
var copy_width := inner_width * 0.42
|
||
status_label.position = Vector2(inner_left, content_top)
|
||
status_label.size = Vector2(copy_width, 26)
|
||
detail_label.position = Vector2(inner_left, content_top + 27.0)
|
||
detail_label.size = Vector2(copy_width, maxf(18.0, rail_height - content_top - content_bottom - 50.0))
|
||
diagnostics_label.position = Vector2(inner_left, rail_height - content_bottom - 23.0)
|
||
diagnostics_label.size = Vector2(copy_width, 23.0)
|
||
progress_bar.visible = false
|
||
boot_progress_value.visible = false
|
||
var action_x := inner_left + copy_width + padding
|
||
var action_width := rail_width - inner_right - action_x
|
||
var visible_actions: Array[Button] = []
|
||
for action in [retry_button, boot_diagnostics_button, boot_exit_button]:
|
||
if action.visible:
|
||
visible_actions.append(action)
|
||
var action_gap := 6.0
|
||
var button_width := (action_width - action_gap * maxf(0.0, visible_actions.size() - 1.0)) / maxf(1.0, visible_actions.size())
|
||
for index in range(visible_actions.size()):
|
||
visible_actions[index].position = Vector2(action_x + index * (button_width + action_gap), (rail_height - 48.0) * 0.5)
|
||
visible_actions[index].size = Vector2(button_width, 48.0)
|
||
else:
|
||
var copy_x := padding + seals_width + maxf(10.0, padding * 0.8)
|
||
var copy_width := rail_width - copy_x - padding
|
||
status_label.position = Vector2(copy_x, content_top)
|
||
status_label.size = Vector2(copy_width, 24.0)
|
||
detail_label.position = Vector2(copy_x, content_top + 24.0)
|
||
detail_label.size = Vector2(copy_width, maxf(18.0, rail_height - content_top - content_bottom - 56.0))
|
||
var progress_y := rail_height - content_bottom - 16.0
|
||
progress_bar.position = Vector2(copy_x, progress_y)
|
||
progress_bar.size = Vector2(maxf(80.0, copy_width - 54.0), 12.0)
|
||
progress_bar.visible = true
|
||
boot_progress_value.position = Vector2(copy_x + copy_width - 50.0, progress_y - 5.0)
|
||
boot_progress_value.size = Vector2(50.0, 22.0)
|
||
boot_progress_value.visible = true
|
||
diagnostics_label.visible = false
|
||
|
||
|
||
func _boot_nine_patch(node_name: String, texture: Texture2D, horizontal_margin: int, vertical_margin: int) -> NinePatchRect:
|
||
var panel := NinePatchRect.new()
|
||
panel.name = node_name
|
||
panel.texture = texture
|
||
panel.patch_margin_left = horizontal_margin
|
||
panel.patch_margin_right = horizontal_margin
|
||
panel.patch_margin_top = vertical_margin
|
||
panel.patch_margin_bottom = vertical_margin
|
||
panel.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return panel
|
||
|
||
|
||
func _boot_action_button(node_name: String, text_value: String, callback: Callable) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.text = text_value
|
||
button.custom_minimum_size = Vector2(72.0, 48.0)
|
||
button.add_theme_font_override("font", UI_FONT)
|
||
button.add_theme_font_size_override("font_size", _scaled(10))
|
||
button.add_theme_color_override("font_color", Color("#f5ead0"))
|
||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||
button.add_theme_stylebox_override("normal", _boot_texture_style(Color.WHITE))
|
||
button.add_theme_stylebox_override("hover", _boot_texture_style(Color("#d9f4ff")))
|
||
button.add_theme_stylebox_override("pressed", _boot_texture_style(Color("#b7cfdd")))
|
||
button.add_theme_stylebox_override("focus", _boot_texture_style(Color("#f4dfa5")))
|
||
button.pressed.connect(callback)
|
||
return button
|
||
|
||
|
||
func _boot_texture_style(modulate: Color) -> StyleBoxTexture:
|
||
var style := StyleBoxTexture.new()
|
||
style.texture = BOOT_ACTION_TAB
|
||
style.texture_margin_left = 20.0
|
||
style.texture_margin_right = 20.0
|
||
style.texture_margin_top = 8.0
|
||
style.texture_margin_bottom = 8.0
|
||
style.modulate_color = modulate
|
||
return style
|
||
|
||
|
||
func _render_boot_state() -> void:
|
||
if status_label == null:
|
||
return
|
||
var identity := boot_identity_plaque.get_node_or_null("BootIdentity") as Label
|
||
if identity != null:
|
||
identity.text = _tr("裂脉远征团", "AETHERBOUND GUILD")
|
||
retry_button.text = _tr("重试", "Retry")
|
||
boot_diagnostics_button.text = _tr("诊断", "Diagnostics")
|
||
boot_exit_button.text = _tr("退出", "Exit")
|
||
var state := String(boot_snapshot.get("state", "running"))
|
||
var step := String(boot_snapshot.get("step", "resources"))
|
||
var total: int = maxi(1, int(boot_snapshot.get("total", 1)))
|
||
var completed := int(boot_snapshot.get("completed", 0))
|
||
progress_bar.max_value = total
|
||
progress_bar.value = completed
|
||
var percent := int(round(float(completed) / float(total) * 100.0))
|
||
boot_progress_value.text = "%d%%" % percent
|
||
var error_code := String(boot_snapshot.get("code", "UNKNOWN"))
|
||
var recoverable := state == "error" and _boot_error_is_recoverable(error_code)
|
||
retry_button.visible = recoverable
|
||
boot_diagnostics_button.visible = state == "error"
|
||
boot_exit_button.visible = state == "error"
|
||
diagnostics_label.visible = state == "error" and boot_diagnostics_expanded
|
||
if state != "error":
|
||
boot_diagnostics_expanded = false
|
||
var complete_seals := 3 if state == "complete" else clampi(int(floor(float(completed) / float(total) * 3.0)), 0, 3)
|
||
for index in range(boot_status_seals.size()):
|
||
var finished := index < complete_seals
|
||
var active := index == complete_seals and state == "running"
|
||
boot_status_seals[index].modulate = Color.WHITE if finished or active else Color(0.62, 0.67, 0.70, 0.88)
|
||
boot_status_marks[index].text = "OK" if finished else "..." if active else str(index + 1)
|
||
boot_status_marks[index].add_theme_color_override("font_color", Color("#9ee9ff") if finished else Color("#f5ead0"))
|
||
match state:
|
||
"error":
|
||
status_label.text = _tr("本地检查需要处理", "Verification needs attention")
|
||
detail_label.text = _tr("本地数据保持不变。可重试的检查会显示重试操作;不安全的存档会保持停止。", "Local data was left unchanged. Recoverable checks offer Retry; unsafe save data remains stopped.")
|
||
diagnostics_label.text = "%s: %s" % [_tr("诊断", "Diagnostic"), error_code]
|
||
"complete":
|
||
status_label.text = _tr("本地检查完成", "Local verification complete")
|
||
detail_label.text = _tr("资源、设置存储与存档访问均已就绪。", "Resources, settings storage and save access are ready.")
|
||
diagnostics_label.text = ""
|
||
_:
|
||
status_label.text = _tr("正在准备公会门槛", "Preparing the guild threshold")
|
||
detail_label.text = _step_text(step)
|
||
diagnostics_label.text = ""
|
||
_layout_boot_screen()
|
||
|
||
|
||
func _boot_error_is_recoverable(code: String) -> bool:
|
||
return code != "SAVE_REVIEW_REQUIRED"
|
||
|
||
|
||
func _step_text(step: String) -> String:
|
||
match step:
|
||
"resources": return _tr("正在检查必需的本地资源...", "Checking required local resources...")
|
||
"storage": return _tr("正在检查本地存储访问...", "Checking local storage access...")
|
||
"preferences": return _tr("正在检查显示与辅助设置...", "Checking display and accessibility settings...")
|
||
"save": return _tr("正在检查最近的本地远征记录...", "Checking the latest local run record...")
|
||
return _tr("正在完成本地检查...", "Completing local verification...")
|
||
|
||
|
||
func _open_after_boot() -> void:
|
||
var loaded: Dictionary = settings_store.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_store.defaults()
|
||
for child in get_children():
|
||
child.free()
|
||
first_launch_setting_buttons.clear()
|
||
first_launch_setting_values.clear()
|
||
var background := TextureRect.new()
|
||
background.name = "FirstLaunchWorld"
|
||
background.texture = FIRST_LAUNCH_BACKGROUND
|
||
background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||
background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(background)
|
||
var shade := ColorRect.new()
|
||
shade.name = "FirstLaunchShade"
|
||
shade.color = Color(0.02, 0.05, 0.07, 0.09)
|
||
shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(shade)
|
||
first_launch_responsive_layout = Control.new()
|
||
first_launch_responsive_layout.name = "FirstLaunchResponsiveLayout"
|
||
first_launch_responsive_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
first_launch_responsive_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(first_launch_responsive_layout)
|
||
first_launch_preview_frame = _first_nine_patch("FirstLaunchPreviewFrame", FIRST_LAUNCH_PREVIEW_FRAME, 86, 74)
|
||
first_launch_responsive_layout.add_child(first_launch_preview_frame)
|
||
first_launch_preview_image = TextureRect.new()
|
||
first_launch_preview_image.name = "FirstLaunchPreviewImage"
|
||
first_launch_preview_image.texture = FIRST_LAUNCH_BACKGROUND
|
||
first_launch_preview_image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
first_launch_preview_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||
first_launch_preview_image.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
first_launch_preview_image.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(first_launch_preview_image)
|
||
var preview_guide := Panel.new()
|
||
preview_guide.name = "FirstLaunchSafeGuide"
|
||
preview_guide.add_theme_stylebox_override("panel", _first_outline_style(Color("#e5f7ff"), 2, 0.82))
|
||
preview_guide.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(preview_guide)
|
||
var preview_heading := _label(_tr("即时预览", "LIVE PREVIEW"), _scaled(13), Color("#f5ead0"))
|
||
preview_heading.name = "FirstLaunchPreviewHeading"
|
||
preview_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
preview_heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(preview_heading)
|
||
first_launch_preview_title_plate = _first_nine_patch("SetupPreviewTitlePlate", FIRST_LAUNCH_VALUE_PLATE, 72, 0)
|
||
first_launch_preview_title_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(first_launch_preview_title_plate)
|
||
var preview_title := _label(_tr("裂脉远征团", "AETHERBOUND GUILD"), _scaled(20), Color("#213744"))
|
||
preview_title.name = "SetupPreviewTitle"
|
||
preview_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
preview_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
preview_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(preview_title)
|
||
first_launch_preview_detail_plate = _first_nine_patch("SetupPreviewDetailPlate", FIRST_LAUNCH_VALUE_PLATE, 72, 0)
|
||
first_launch_preview_detail_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(first_launch_preview_detail_plate)
|
||
var preview_detail := _label(_tr("安全区 · 字号 · 字幕", "Safe area · text · captions"), _scaled(10), Color("#3c403e"))
|
||
preview_detail.name = "SetupPreviewDetail"
|
||
preview_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
preview_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
preview_detail.clip_text = true
|
||
preview_detail.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
preview_detail.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
preview_detail.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_preview_frame.add_child(preview_detail)
|
||
|
||
first_launch_settings_panel = Control.new()
|
||
first_launch_settings_panel.name = "FirstLaunchControls"
|
||
first_launch_settings_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
first_launch_responsive_layout.add_child(first_launch_settings_panel)
|
||
var heading := _label(_tr("首次启动设置", "FIRST-LAUNCH SETUP"), _scaled(22), Color("#f5ead0"))
|
||
heading.name = "FirstLaunchHeading"
|
||
heading.add_theme_color_override("font_shadow_color", Color(0.02, 0.06, 0.08, 0.72))
|
||
heading.add_theme_constant_override("shadow_offset_x", 1)
|
||
heading.add_theme_constant_override("shadow_offset_y", 2)
|
||
first_launch_settings_panel.add_child(heading)
|
||
var subheading := _label(_tr("这些选项只影响显示与辅助功能。", "These choices affect presentation and accessibility only."), _scaled(12), Color("#f5ead0"))
|
||
subheading.name = "FirstLaunchSubheading"
|
||
subheading.add_theme_color_override("font_shadow_color", Color(0.02, 0.06, 0.08, 0.68))
|
||
subheading.add_theme_constant_override("shadow_offset_x", 1)
|
||
subheading.add_theme_constant_override("shadow_offset_y", 1)
|
||
subheading.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
subheading.clip_text = true
|
||
subheading.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
subheading.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
first_launch_settings_panel.add_child(subheading)
|
||
var rows := [
|
||
{"id": "SetupLanguage", "label": ["语言", "Language"], "icon": "A", "kind": "choice", "callback": Callable(self, "toggle_setup_locale")},
|
||
{"id": "SetupSafeArea", "label": ["安全区", "Safe area"], "icon": "S", "kind": "choice", "callback": Callable(self, "cycle_setup_safe_area")},
|
||
{"id": "SetupTextScale", "label": ["字体", "Text scale"], "icon": "T", "kind": "choice", "callback": Callable(self, "cycle_setup_text_scale")},
|
||
{"id": "SetupReducedMotion", "label": ["减少动画", "Reduced motion"], "icon": "M", "kind": "toggle", "callback": Callable(self, "toggle_setup_reduced_motion")},
|
||
{"id": "SetupReducedFlashes", "label": ["减少闪烁", "Reduced flashes"], "icon": "F", "kind": "toggle", "callback": Callable(self, "toggle_setup_reduced_flashes")},
|
||
{"id": "SetupCaptions", "label": ["字幕与事件标签", "Captions"], "icon": "C", "kind": "toggle", "callback": Callable(self, "toggle_setup_captions")}
|
||
]
|
||
for row_data in rows:
|
||
var row := _first_setting_row(row_data)
|
||
first_launch_settings_panel.add_child(row)
|
||
var apply := _first_action_button("SetupApply", _tr("应用并进入标题首页", "Apply and continue to Title"), apply_first_launch, FIRST_LAUNCH_APPLY_COMMAND, 58)
|
||
apply.focus_mode = Control.FOCUS_ALL
|
||
first_launch_apply = apply
|
||
first_launch_settings_panel.add_child(apply)
|
||
first_launch_error = _label("", _scaled(11), Color("#914b32"))
|
||
first_launch_error.name = "SetupError"
|
||
first_launch_error.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
first_launch_settings_panel.add_child(first_launch_error)
|
||
_layout_first_launch()
|
||
_refresh_first_launch_labels()
|
||
|
||
|
||
func _layout_first_launch() -> void:
|
||
if not is_instance_valid(first_launch_responsive_layout) or not is_instance_valid(first_launch_preview_frame) or not is_instance_valid(first_launch_settings_panel):
|
||
return
|
||
var available := size
|
||
if available.x <= 0.0 or available.y <= 0.0:
|
||
return
|
||
var safe_x := clampf(available.x * 0.035, 18.0, 48.0)
|
||
var safe_y := clampf(available.y * 0.045, 14.0, 40.0)
|
||
var gap := 16.0 if available.y >= 420.0 else 8.0
|
||
var usable_width := available.x - safe_x * 2.0
|
||
var settings_width := clampf(usable_width * (0.44 if available.x >= 900.0 else 0.48), 300.0, 520.0)
|
||
var preview_width := maxf(270.0, usable_width - settings_width - gap)
|
||
var content_height := available.y - safe_y * 2.0
|
||
var preview_height := maxf(190.0, content_height * (0.78 if available.y >= 420.0 else 0.70))
|
||
var preview_x := safe_x
|
||
var preview_y := safe_y + (content_height - preview_height) * 0.38
|
||
first_launch_preview_frame.position = Vector2(preview_x, preview_y)
|
||
first_launch_preview_frame.size = Vector2(preview_width, preview_height)
|
||
first_launch_preview_image.position = Vector2(18.0, 18.0)
|
||
first_launch_preview_image.size = Vector2(preview_width - 36.0, preview_height - 36.0)
|
||
var guide := first_launch_preview_frame.get_node("FirstLaunchSafeGuide") as Control
|
||
var safe_guide_inset := clampf(24.0 + float(setup_draft.safe_area) * 0.35, 24.0, minf(preview_width, preview_height) * 0.22)
|
||
guide.position = Vector2(safe_guide_inset, safe_guide_inset)
|
||
guide.size = Vector2(preview_width - safe_guide_inset * 2.0, preview_height - safe_guide_inset * 2.0)
|
||
var preview_heading := first_launch_preview_frame.get_node("FirstLaunchPreviewHeading") as Label
|
||
preview_heading.position = Vector2(26.0, 22.0)
|
||
preview_heading.size = Vector2(preview_width - 52.0, 26.0)
|
||
var preview_title := first_launch_preview_frame.get_node("SetupPreviewTitle") as Label
|
||
var title_plate_rect := Rect2(preview_width * 0.15, preview_height * 0.37, preview_width * 0.70, 48.0)
|
||
first_launch_preview_title_plate.position = title_plate_rect.position
|
||
first_launch_preview_title_plate.size = title_plate_rect.size
|
||
preview_title.position = title_plate_rect.position + Vector2(12.0, 3.0)
|
||
preview_title.size = title_plate_rect.size - Vector2(24.0, 6.0)
|
||
var preview_detail := first_launch_preview_frame.get_node("SetupPreviewDetail") as Label
|
||
var detail_plate_rect := Rect2(preview_width * 0.12, preview_height - 73.0, preview_width * 0.76, 38.0)
|
||
first_launch_preview_detail_plate.position = detail_plate_rect.position
|
||
first_launch_preview_detail_plate.size = detail_plate_rect.size
|
||
preview_detail.position = detail_plate_rect.position + Vector2(46.0, 2.0)
|
||
preview_detail.size = detail_plate_rect.size - Vector2(92.0, 4.0)
|
||
first_launch_settings_panel.position = Vector2(preview_x + preview_width + gap, safe_y)
|
||
first_launch_settings_panel.size = Vector2(settings_width, content_height)
|
||
var heading := first_launch_settings_panel.get_node("FirstLaunchHeading") as Label
|
||
heading.position = Vector2(0, 0)
|
||
heading.size = Vector2(settings_width, 32.0)
|
||
var subheading := first_launch_settings_panel.get_node("FirstLaunchSubheading") as Label
|
||
subheading.position = Vector2(0, 32.0)
|
||
subheading.size = Vector2(settings_width, 30.0)
|
||
var row_y := 68.0
|
||
var row_gap := 6.0 if available.y >= 420.0 else 4.0
|
||
var row_height := clampf((content_height - 150.0 - row_gap * 5.0) / 6.0, 48.0, 68.0)
|
||
var row_names := ["SetupLanguage", "SetupSafeArea", "SetupTextScale", "SetupReducedMotion", "SetupReducedFlashes", "SetupCaptions"]
|
||
var compact_columns := available.y < 400.0
|
||
if compact_columns:
|
||
row_height = 48.0
|
||
var compact_width := (settings_width - row_gap) * 0.5
|
||
for index in range(row_names.size()):
|
||
var compact_row := first_launch_setting_buttons[row_names[index]] as Button
|
||
compact_row.position = Vector2((index % 2) * (compact_width + row_gap), row_y + floori(index / 2.0) * (row_height + row_gap))
|
||
compact_row.size = Vector2(compact_width, row_height)
|
||
_layout_first_setting_row(compact_row)
|
||
row_y += 3.0 * (row_height + row_gap)
|
||
else:
|
||
for row_data in row_names:
|
||
var row := first_launch_setting_buttons[row_data] as Button
|
||
row.position = Vector2(0, row_y)
|
||
row.size = Vector2(settings_width, row_height)
|
||
_layout_first_setting_row(row)
|
||
row_y += row_height + row_gap
|
||
first_launch_apply.position = Vector2(0, minf(row_y + 6.0, content_height - 58.0))
|
||
first_launch_apply.size = Vector2(settings_width, 58.0)
|
||
first_launch_error.position = Vector2(0, first_launch_apply.position.y + 60.0)
|
||
first_launch_error.size = Vector2(settings_width, maxf(24.0, content_height - first_launch_error.position.y))
|
||
|
||
|
||
func _refresh_first_launch_labels() -> void:
|
||
if first_launch_settings_panel == null:
|
||
return
|
||
var values := {
|
||
"SetupLanguage": _tr("简体中文", "English"),
|
||
"SetupSafeArea": "%d px" % int(setup_draft.safe_area),
|
||
"SetupTextScale": "%d%%" % int(round(float(setup_draft.text_scale) * 100.0)),
|
||
"SetupReducedMotion": _tr("开启", "On") if bool(setup_draft.reduced_motion) else _tr("关闭", "Off"),
|
||
"SetupReducedFlashes": _tr("开启", "On") if bool(setup_draft.reduced_flashes) else _tr("关闭", "Off"),
|
||
"SetupCaptions": _tr("开启", "On") if bool(setup_draft.captions) else _tr("关闭", "Off")
|
||
}
|
||
for node_name in values:
|
||
var value_label := first_launch_setting_values.get(node_name) as Label
|
||
if value_label != null:
|
||
value_label.text = String(values[node_name])
|
||
var heading := first_launch_settings_panel.get_node_or_null("FirstLaunchHeading") as Label
|
||
if heading != null:
|
||
heading.text = _tr("首次启动设置", "FIRST-LAUNCH SETUP")
|
||
var subheading := first_launch_settings_panel.get_node_or_null("FirstLaunchSubheading") as Label
|
||
if subheading != null:
|
||
subheading.text = _tr("这些选项只影响显示与辅助功能。", "These choices affect presentation and accessibility only.")
|
||
if first_launch_apply != null:
|
||
var label := first_launch_apply.get_node_or_null("ButtonLabel") as Label
|
||
if label != null:
|
||
label.text = _tr("应用并进入标题首页", "Apply and continue to Title")
|
||
|
||
|
||
func _first_setting_row(row_data: Dictionary) -> Button:
|
||
var node_name := String(row_data.id)
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
for style_name in ["normal", "hover", "pressed", "focus", "disabled", "hover_pressed"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
var background := _first_nine_patch("Background", FIRST_LAUNCH_SETTING_ROW, 36, 0)
|
||
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
button.add_child(background)
|
||
var icon := TextureRect.new()
|
||
icon.name = "Icon"
|
||
icon.texture = FIRST_LAUNCH_SETTING_ICON
|
||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(icon)
|
||
var icon_label := _label(String(row_data.icon), _scaled(15), Color("#f5ead0"))
|
||
icon_label.name = "IconLabel"
|
||
icon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
icon_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
icon_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
icon.add_child(icon_label)
|
||
var label := _label(_tr(String(row_data.label[0]), String(row_data.label[1])), _scaled(13), Color("#202a31"))
|
||
label.name = "RowLabel"
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(label)
|
||
var value_plate := TextureRect.new()
|
||
value_plate.name = "ValuePlate"
|
||
value_plate.texture = FIRST_LAUNCH_VALUE_PLATE
|
||
value_plate.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
value_plate.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
value_plate.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
value_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(value_plate)
|
||
var value_label := _label("", _scaled(12), Color("#202a31"))
|
||
value_label.name = "ValueLabel"
|
||
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
value_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
value_plate.add_child(value_label)
|
||
first_launch_setting_buttons[node_name] = button
|
||
first_launch_setting_values[node_name] = value_label
|
||
button.pressed.connect(row_data.callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _layout_first_setting_row(button: Button) -> void:
|
||
var row_size := button.size
|
||
var icon := button.get_node("Icon") as TextureRect
|
||
var label := button.get_node("RowLabel") as Label
|
||
var value_plate := button.get_node("ValuePlate") as TextureRect
|
||
var icon_size := clampf(row_size.y - 12.0, 32.0, 54.0)
|
||
var value_width := clampf(row_size.x * 0.30, 68.0, 132.0)
|
||
icon.position = Vector2(7.0, (row_size.y - icon_size) * 0.5)
|
||
icon.size = Vector2(icon_size, icon_size)
|
||
var value_height := maxf(32.0, row_size.y - 12.0)
|
||
value_plate.position = Vector2(row_size.x - value_width - 8.0, (row_size.y - value_height) * 0.5)
|
||
value_plate.size = Vector2(value_width, value_height)
|
||
label.position = Vector2(icon.position.x + icon_size + 6.0, 0.0)
|
||
label.size = Vector2(maxf(20.0, value_plate.position.x - label.position.x - 5.0), row_size.y)
|
||
var value_label := value_plate.get_node("ValueLabel") as Label
|
||
value_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
|
||
|
||
func _first_action_button(node_name: String, text_value: String, callback: Callable, texture: Texture2D, height: float) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
for style_name in ["normal", "hover", "pressed", "focus", "disabled", "hover_pressed"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
var frame := _first_nine_patch("CommandFrame", texture, 60, 0)
|
||
frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
button.add_child(frame)
|
||
var label := _label(text_value, _scaled(15), Color("#213744"))
|
||
label.name = "ButtonLabel"
|
||
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(label)
|
||
button.custom_minimum_size = Vector2(0, height)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _first_nine_patch(node_name: String, texture: Texture2D, horizontal_margin: int, vertical_margin: int) -> NinePatchRect:
|
||
var panel := NinePatchRect.new()
|
||
panel.name = node_name
|
||
panel.texture = texture
|
||
panel.patch_margin_left = horizontal_margin
|
||
panel.patch_margin_right = horizontal_margin
|
||
panel.patch_margin_top = vertical_margin
|
||
panel.patch_margin_bottom = vertical_margin
|
||
panel.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return panel
|
||
|
||
|
||
func _first_outline_style(color: Color, width: int, alpha: float) -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = Color(0, 0, 0, 0)
|
||
style.border_color = Color(color.r, color.g, color.b, alpha)
|
||
style.set_border_width_all(width)
|
||
return style
|
||
|
||
|
||
func toggle_setup_locale() -> void:
|
||
setup_draft.locale = "en" if String(setup_draft.locale) == "zh_CN" else "zh_CN"
|
||
settings = setup_draft.duplicate(true)
|
||
show_first_launch()
|
||
|
||
|
||
func cycle_setup_safe_area() -> void:
|
||
var options := SettingsStore.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 := SettingsStore.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_store.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("#8ab8d6")
|
||
world_extension.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
world_extension.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(world_extension)
|
||
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 = TITLE_MASTER_V002
|
||
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
|
||
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.105, 0.14, 0.275, 0.25))
|
||
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
layout.add_child(header)
|
||
var overline := _label(_tr("风路公会 · 远征入口", "WIND-ROAD GUILD"), _scaled(6), 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(14 if locale == "zh_CN" else 11), 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.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
header.add_child(identity)
|
||
var chapter := _label(_tr("第一远征纪", "THE FIRST CHRONICLE"), _scaled(6), 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", "", "", show_settings_placeholder, Rect2(0.61, 0.755, 0.075, 0.13), "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.645, 0.78, 0.13, 0.10), "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
|
||
guild_company = resolved
|
||
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:
|
||
_show_guild_master_composite(company)
|
||
return
|
||
|
||
|
||
func _show_guild_master_composite(company: Dictionary) -> void:
|
||
_show_guild_master_composite_exact(company)
|
||
return
|
||
|
||
|
||
func _show_guild_master_composite_exact(company: Dictionary) -> void:
|
||
var resolved: Dictionary = company.duplicate(true)
|
||
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)))
|
||
guild_master_design_canvas = null
|
||
for child in get_children():
|
||
child.free()
|
||
var world_extension := TextureRect.new()
|
||
world_extension.name = "GuildMasterWorldExtension"
|
||
world_extension.texture = GUILD_DESK_WORLD
|
||
world_extension.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
world_extension.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||
world_extension.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
world_extension.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
world_extension.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(world_extension)
|
||
var design_canvas := Control.new()
|
||
design_canvas.name = "GuildMasterDesignCanvas"
|
||
design_canvas.clip_contents = true
|
||
design_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(design_canvas)
|
||
guild_master_design_canvas = design_canvas
|
||
_layout_guild_master_canvas()
|
||
var background := TextureRect.new()
|
||
background.name = "GuildMasterLayeredWorld"
|
||
background.texture = GUILD_DESK_WORLD
|
||
background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
background.stretch_mode = TextureRect.STRETCH_SCALE
|
||
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
|
||
design_canvas.add_child(background)
|
||
var root := Control.new()
|
||
root.name = "GuildAnchorRoot"
|
||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
design_canvas.add_child(root)
|
||
|
||
_guild_desk_segmented_rail(
|
||
root,
|
||
"GuildTopRail",
|
||
Rect2(0.012, 0.010, 0.976, 0.115),
|
||
GUILD_DESK_TOP_LEFT,
|
||
GUILD_DESK_TOP_CENTER,
|
||
GUILD_DESK_TOP_RIGHT,
|
||
0.055,
|
||
0.046
|
||
)
|
||
_guild_desk_segmented_rail(
|
||
root,
|
||
"GuildBottomRail",
|
||
Rect2(0.010, 0.865, 0.980, 0.130),
|
||
GUILD_DESK_BOTTOM_LEFT,
|
||
GUILD_DESK_BOTTOM_CENTER,
|
||
GUILD_DESK_BOTTOM_RIGHT,
|
||
0.080,
|
||
0.090
|
||
)
|
||
|
||
var active_contract: bool = not resolved.active_contract.is_empty()
|
||
var title_plate := _guild_master_overlay_plate(root, "GuildTitlePlate", Rect2(0.090, 0.026, 0.137, 0.076), Color("#f3dfae"), Color("#9d7839"), 2, 14)
|
||
var title := _label(_tr("公会大厅", "GUILD HALL"), _guild_text_size(11), Color("#23323a"))
|
||
title.name = "GuildAnchorTitle"
|
||
_set_normalized_rect(title, Rect2(0.07, 0.04, 0.86, 0.54))
|
||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
title_plate.add_child(title)
|
||
var status := _label(_tr("持续远征团", "PERSISTENT COMPANY"), _guild_text_size(6), Color("#5e513a"))
|
||
status.name = "GuildAnchorStatus"
|
||
_set_normalized_rect(status, Rect2(0.07, 0.57, 0.86, 0.34))
|
||
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
status.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
title_plate.add_child(status)
|
||
var pressure_copy := "0"
|
||
var coin_copy := "--"
|
||
if active_contract:
|
||
pressure_copy = _difficulty_name(String(resolved.active_contract.get("difficulty", "standard")))
|
||
coin_copy = "%d" % int(resolved.active_contract.get("coin", 0))
|
||
var pressure_status := _guild_desk_status_slot(root, "GuildProgressStatus", Rect2(0.595, 0.020, 0.110, 0.090), pressure_copy, "pressure")
|
||
pressure_status.tooltip_text = _tr("当前委托压力", "Current Contract pressure")
|
||
var coin_status := _guild_desk_status_slot(root, "GuildCoinStatus", Rect2(0.735, 0.020, 0.100, 0.090), coin_copy, "coin")
|
||
coin_status.tooltip_text = _tr("当前委托金币", "Current Contract Coin")
|
||
var company_status := _guild_desk_status_slot(root, "GuildAnchorCompanyStatus", Rect2(0.845, 0.020, 0.090, 0.090), "%d" % resolved.owned_recruits.size(), "company")
|
||
company_status.tooltip_text = _tr("远征团成员数", "Expedition Company members")
|
||
var settings_button := _guild_desk_icon_button(root, "GuildSettings", _tr("设置", "Settings"), "settings", show_settings_from_guild, Rect2(0.922, 0.004, 0.076, 0.136))
|
||
settings_button.tooltip_text = _tr("设置", "Settings")
|
||
var title_button := _guild_master_hit_button("GuildTitle", _tr("返回标题", "Back to Title"), back_to_title, Rect2(0.000, 0.000, 0.075, 0.140))
|
||
title_button.set_meta("art_owned_control", "GuildTopRailLeft")
|
||
var title_command_label := _label(_tr("标题", "Title"), _guild_text_size(8), Color.TRANSPARENT)
|
||
title_command_label.name = "GuildTitleCommandLabel"
|
||
title_command_label.visible = false
|
||
title_command_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
title_button.add_child(title_command_label)
|
||
root.add_child(title_button)
|
||
|
||
var roster := Control.new()
|
||
roster.name = "GuildRoster"
|
||
roster.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
roster.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
root.add_child(roster)
|
||
for index in range(mini(2, resolved.owned_recruits.size())):
|
||
var recruit: Dictionary = resolved.owned_recruits[index]
|
||
var member_rect := Rect2(0.190 + index * 0.190, 0.250 - index * 0.010, 0.280, 0.600)
|
||
roster.add_child(_guild_desk_member(index, recruit, member_rect))
|
||
var identity_copy := ""
|
||
if String(settings.get("locale", "zh_CN")) == "zh_CN":
|
||
identity_copy = "%s · %s" % [_recruit_name(recruit), _recruit_profession_name(recruit)]
|
||
else:
|
||
identity_copy = "%s · %s" % [_recruit_name(recruit), _recruit_profession_name(recruit)]
|
||
var identity_plate := _guild_master_overlay_plate(root, "GuildMasterIdentityPlate%d" % index, Rect2(0.225 + index * 0.190, 0.805, 0.180, 0.050), Color(0.025, 0.09, 0.14, 0.86), Color(0.68, 0.51, 0.26, 0.64), 1, 2)
|
||
var identity := _label(identity_copy, _guild_text_size(8), Color("#fff4d6"))
|
||
identity.name = "GuildMasterIdentity%d" % index
|
||
identity.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
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(0.02, 0.04, 0.05, 0.92))
|
||
identity.add_theme_constant_override("outline_size", maxi(1, _guild_text_size(1)))
|
||
identity.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
identity_plate.add_child(identity)
|
||
|
||
var kiosk := TextureRect.new()
|
||
kiosk.name = "GuildMasterKioskTexture"
|
||
kiosk.texture = GUILD_DESK_CONTRACT_BOARD
|
||
kiosk.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
kiosk.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
kiosk.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
_set_normalized_rect(kiosk, Rect2(0.615, 0.205, 0.370, 0.655))
|
||
kiosk.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
root.add_child(kiosk)
|
||
|
||
var kiosk_copy_area := Control.new()
|
||
kiosk_copy_area.name = "GuildKioskCopyArea"
|
||
_set_normalized_rect(kiosk_copy_area, Rect2(0.714, 0.433, 0.155, 0.305))
|
||
kiosk_copy_area.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
root.add_child(kiosk_copy_area)
|
||
var kiosk_text := _label(_tr("远征市场", "EXPEDITION MARKET") if active_contract else _tr("委托板", "CONTRACT BOARD"), _guild_text_size(11), Color("#2b3030"))
|
||
kiosk_text.name = "GuildKioskText"
|
||
_set_normalized_rect(kiosk_text, Rect2(0.03, 0.04, 0.94, 0.20))
|
||
kiosk_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
kiosk_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
kiosk_text.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
kiosk_copy_area.add_child(kiosk_text)
|
||
var contract_reason := _label(_tr("委托进行中", "CONTRACT ACTIVE") if active_contract else _tr("可用 · 选择难度", "AVAILABLE · CHOOSE DIFFICULTY"), _guild_text_size(8), Color("#4b3724"))
|
||
contract_reason.name = "GuildContractReason"
|
||
_set_normalized_rect(contract_reason, Rect2(0.04, 0.31, 0.92, 0.24))
|
||
contract_reason.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
contract_reason.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
contract_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
contract_reason.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
kiosk_copy_area.add_child(contract_reason)
|
||
var market_reason := _label(_tr("市场已开放", "Market open") if active_contract else _tr("市场锁定 · 先确认首个委托", "Market locked · Confirm first Contract"), _guild_text_size(8), Color("#6a4a2d"))
|
||
market_reason.name = "GuildMarketReason"
|
||
_set_normalized_rect(market_reason, Rect2(0.04, 0.58, 0.92, 0.28))
|
||
market_reason.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
market_reason.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
market_reason.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
market_reason.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
kiosk_copy_area.add_child(market_reason)
|
||
var notice := _label("", _guild_text_size(8), Color("#f0ca75"))
|
||
notice.name = "GuildNotice"
|
||
_set_normalized_rect(notice, Rect2(0.03, 0.86, 0.94, 0.13))
|
||
notice.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
notice.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
notice.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
kiosk_copy_area.add_child(notice)
|
||
var roster_button := _guild_desk_nav_button(root, "GuildRosterButton", "GuildRosterCommandLabel", _tr("成员", "Roster"), "roster", show_roster.bind(resolved), 0, false)
|
||
var equipment_button := _guild_desk_nav_button(root, "GuildEquipment", "GuildEquipmentCommandLabel", _tr("装备", "Gear"), "equipment", func(): _set_guild_notice(_tr("建立委托后可在市场查看装备。", "Create a Contract to review equipment in its Market.")), 1, not active_contract)
|
||
var party_button := _guild_desk_nav_button(root, "GuildParty", "GuildPartyCommandLabel", _tr("队列", "Party"), "party", open_party_line if active_contract else func(): _set_guild_notice(_tr("先建立委托。", "Create a Contract first.")), 2, not active_contract)
|
||
var risk_button := _guild_desk_nav_button(root, "GuildRisk", "GuildRouteStatusLabel", _tr("风险", "Risk"), "risk", open_risk_board if active_contract else func(): _set_guild_notice(_tr("先建立委托。", "Create a Contract first.")), 3, not active_contract)
|
||
for nav_button in [title_button, roster_button, equipment_button, party_button, risk_button]:
|
||
nav_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
|
||
var contract := _guild_master_hit_button("GuildContractActive" if active_contract else "GuildContractBoard", _tr("查看委托板", "Open Contract Board"), show_contract_choice, Rect2(0.615, 0.205, 0.370, 0.655))
|
||
contract.disabled = active_contract
|
||
if active_contract:
|
||
contract.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
root.add_child(contract)
|
||
var market := _guild_master_hit_button("GuildMarket" if active_contract else "GuildMarketUnavailable", _tr("进入市场", "Enter Market"), show_market_handoff.bind(resolved) if active_contract else func(): _set_guild_notice(_tr("请先确认首个委托。", "Confirm the first Contract first.")), Rect2(0.850, 0.840, 0.140, 0.155))
|
||
market.disabled = not active_contract
|
||
root.add_child(market)
|
||
for marker_name in ["GuildContractSticker", "GuildMarketSticker"]:
|
||
var marker := Control.new()
|
||
marker.name = marker_name
|
||
marker.visible = false
|
||
root.add_child(marker)
|
||
contract.call_deferred("grab_focus")
|
||
|
||
|
||
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
|
||
current_screen = "roster"
|
||
if roster_selected_recruit_id.is_empty() or _recruit_by_id(resolved, roster_selected_recruit_id).is_empty():
|
||
roster_selected_recruit_id = String(resolved.owned_recruits[0].recruit_id) if not resolved.owned_recruits.is_empty() else ""
|
||
_build_world_base()
|
||
var frame_safe := _framed_page_safe("Roster")
|
||
frame_safe.add_child(_entry_chrome("RosterFrame", "initial_company_scene_frame"))
|
||
var safe := _content_page_safe("Roster")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "RosterLayout"
|
||
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 52)
|
||
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("远征团成员", "COMPANY ROSTER"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_tr("稳定身份 · 个人成长 · 装备与战斗历史", "STABLE IDENTITY · PERSONAL GROWTH · EQUIPMENT & BATTLE HISTORY"), _scaled(10), Color("#e8d39d")))
|
||
var count := _label(_tr("成员 %d", "%d MEMBERS") % resolved.owned_recruits.size(), _scaled(12), Color("#73b9e7"))
|
||
count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(count)
|
||
var body := HBoxContainer.new()
|
||
body.name = "RosterBody"
|
||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_theme_constant_override("separation", 14)
|
||
layout.add_child(body)
|
||
var list := VBoxContainer.new()
|
||
list.name = "RosterList"
|
||
list.custom_minimum_size = Vector2(minf(420.0, size.x * 0.54), 0)
|
||
list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
list.add_theme_constant_override("separation", 5)
|
||
body.add_child(list)
|
||
for index in range(resolved.owned_recruits.size()):
|
||
list.add_child(_roster_member_button(index, resolved.owned_recruits[index]))
|
||
var detail := VBoxContainer.new()
|
||
detail.name = "RosterDetail"
|
||
detail.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
detail.add_theme_constant_override("separation", 5)
|
||
body.add_child(detail)
|
||
_render_roster_detail(detail, resolved, roster_selected_recruit_id)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "RosterFooter"
|
||
footer.custom_minimum_size = Vector2(0, 44 if size.y < 500.0 else 52)
|
||
footer.add_theme_constant_override("separation", 8)
|
||
layout.add_child(footer)
|
||
footer.add_child(_company_button("RosterBackGuild", _tr("返回公会", "Back to Guild"), back_to_guild, "company_utility", "back"))
|
||
var roster_back := footer.get_child(footer.get_child_count() - 1) as Button
|
||
roster_back.custom_minimum_size = Vector2(190, footer.custom_minimum_size.y)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var dismiss_eligible: bool = not roster_selected_recruit_id.is_empty() and resolved.owned_recruits.size() > 1 and String(resolved.active_contract.get("status", "")) == "market"
|
||
var dismiss := _company_button("RosterDismiss", _tr("解雇", "Dismiss"), open_dismissal_review.bind(roster_selected_recruit_id), "company_secondary", "utility")
|
||
dismiss.custom_minimum_size = Vector2(190, footer.custom_minimum_size.y)
|
||
_set_entry_button_disabled(dismiss, not dismiss_eligible)
|
||
dismiss.tooltip_text = _tr("审查 %s 的解雇后果", "Review dismissal consequences for %s") % _recruit_name(_recruit_by_id(resolved, roster_selected_recruit_id)) if dismiss_eligible else _tr("最后一名成员不可解雇,或当前阶段不是 Market", "The last member cannot be dismissed, or the current phase is not Market")
|
||
footer.add_child(dismiss)
|
||
var detail_button := _company_button("RosterOpenDetail", _tr("查看详情", "Open Detail"), show_recruit_detail.bind(roster_selected_recruit_id), "company_primary", "inspect")
|
||
detail_button.custom_minimum_size = Vector2(190, footer.custom_minimum_size.y)
|
||
footer.add_child(detail_button)
|
||
find_child("RosterOpenDetail", true, false).call_deferred("grab_focus")
|
||
|
||
|
||
func show_recruit_detail(recruit_id: String) -> void:
|
||
var loaded: Dictionary = 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)
|
||
roster_selected_recruit_id = recruit_id
|
||
current_screen = "recruit_detail"
|
||
_build_world_base()
|
||
var frame_safe := _framed_page_safe("RecruitDetail")
|
||
frame_safe.add_child(_entry_chrome("RecruitDetailFrame", "initial_company_scene_frame"))
|
||
var safe := _content_page_safe("RecruitDetail")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "RecruitDetailLayout"
|
||
layout.add_theme_constant_override("separation", 5)
|
||
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("成员详情", "RECRUIT DETAIL"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_recruit_name(recruit), _scaled(12), Color("#e8d39d")))
|
||
var state := _label(_tr("保留中", "RETAINED"), _scaled(11), Color("#9ed7aa"))
|
||
state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(state)
|
||
var body := HBoxContainer.new()
|
||
body.name = "RecruitDetailBody"
|
||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_theme_constant_override("separation", 16)
|
||
layout.add_child(body)
|
||
var portrait_stage := Control.new()
|
||
portrait_stage.name = "RecruitDetailPortrait"
|
||
portrait_stage.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
portrait_stage.size_flags_stretch_ratio = 0.8
|
||
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 details := VBoxContainer.new()
|
||
details.name = "RecruitDetailFacts"
|
||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
details.size_flags_stretch_ratio = 1.2
|
||
details.add_theme_constant_override("separation", 5)
|
||
body.add_child(details)
|
||
_add_review_detail(details, "RecruitDetailProfession", _tr("职业", "PROFESSION"), "%s · %s" % [_recruit_profession_name(recruit), String(recruit.profession_job)])
|
||
_add_review_detail(details, "RecruitDetailTrait", _tr("特质", "TRAIT"), _recruit_trait_name(recruit))
|
||
_add_review_detail(details, "RecruitDetailGrowth", _tr("个人成长", "PERSONAL GROWTH"), _tr("等级 %d · XP %d", "Level %d · XP %d") % [int(recruit.level), int(recruit.xp)])
|
||
_add_review_detail(details, "RecruitDetailEquipment", _tr("当前装备", "EQUIPPED ITEM"), _recruit_item_name(recruit))
|
||
var history: Dictionary = recruit.get("battle_history", {})
|
||
_add_review_detail(details, "RecruitDetailHistory", _tr("战斗历史", "BATTLE HISTORY"), _tr("参战 %d · 胜利 %d · 击倒 %d", "Entered %d · Victories %d · Knockouts %d") % [int(history.get("battles_entered", 0)), int(history.get("victories", 0)), int(history.get("knockouts", 0))])
|
||
var note := _label(_tr("解雇、晋升与更多构筑操作将在后续 DEV-6 步骤启用。", "Dismissal, promotion and further build actions are enabled in later DEV-6 steps."), _scaled(10), Color("#e8d39d"))
|
||
note.name = "RecruitDetailFutureReason"
|
||
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
details.add_child(note)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "RecruitDetailFooter"
|
||
footer.custom_minimum_size = Vector2(0, 44 if size.y < 500.0 else 52)
|
||
layout.add_child(footer)
|
||
var detail_back := _company_button("RecruitDetailBackRoster", _tr("返回成员", "Back to Roster"), show_roster.bind(guild_company), "company_utility", "back")
|
||
detail_back.custom_minimum_size = Vector2(190, footer.custom_minimum_size.y)
|
||
footer.add_child(detail_back)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var dismiss_eligible: bool = guild_company.owned_recruits.size() > 1 and String(guild_company.active_contract.get("status", "")) == "market"
|
||
var dismiss := _company_button("RecruitDetailDismiss", _tr("解雇", "Dismiss"), open_dismissal_review.bind(recruit_id), "company_secondary", "utility")
|
||
dismiss.custom_minimum_size = Vector2(190, footer.custom_minimum_size.y)
|
||
_set_entry_button_disabled(dismiss, not dismiss_eligible)
|
||
dismiss.tooltip_text = _tr("审查 %s 的解雇后果", "Review dismissal consequences for %s") % _recruit_name(recruit) if dismiss_eligible else _tr("最后一名成员不可解雇,或当前阶段不是 Market", "The last member cannot be dismissed, or the current phase is not Market")
|
||
footer.add_child(dismiss)
|
||
find_child("RecruitDetailBackRoster", true, false).call_deferred("grab_focus")
|
||
|
||
|
||
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", {})
|
||
if contract.is_empty() or String(contract.get("status", "")) != "market":
|
||
contract_return_notice = _tr("当前委托已不在可返回的 Market 阶段。", "The current Contract is no longer in a returnable Market phase.")
|
||
show_market(loaded.company)
|
||
return
|
||
guild_company = loaded.company.duplicate(true)
|
||
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)
|
||
confirm.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 _roster_member_button(index: int, recruit: Dictionary) -> Button:
|
||
var button := _entry_button_base("RosterMember%d" % index, select_roster_recruit.bind(String(recruit.recruit_id)), "company_primary" if String(recruit.recruit_id) == roster_selected_recruit_id else "company_secondary")
|
||
button.set_meta("recruit_id", String(recruit.recruit_id))
|
||
button.custom_minimum_size = Vector2(0, 48 if size.y < 380.0 else (62 if size.y < 500.0 else 72))
|
||
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.position = Vector2(8, 3)
|
||
portrait.size = Vector2(42 if size.y < 380.0 else 52, 42 if size.y < 380.0 else 54)
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(portrait)
|
||
var name := _label(_recruit_name(recruit), _scaled(14), Color("#f5ead0"))
|
||
name.position = Vector2(58 if size.y < 380.0 else 70, 3 if size.y < 380.0 else 6)
|
||
name.size = Vector2(230, 20 if size.y < 380.0 else 24)
|
||
name.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(name)
|
||
var facts := _label(_tr("%s · 等级 %d · XP %d", "%s · Level %d · XP %d") % [_recruit_profession_name(recruit), int(recruit.level), int(recruit.xp)], _scaled(10), Color("#aebbc2"))
|
||
facts.position = Vector2(58 if size.y < 380.0 else 70, 24 if size.y < 380.0 else 31)
|
||
facts.size = Vector2(290, 20 if size.y < 380.0 else 24)
|
||
facts.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
facts.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(facts)
|
||
return button
|
||
|
||
|
||
func select_roster_recruit(recruit_id: String) -> void:
|
||
roster_selected_recruit_id = recruit_id
|
||
show_roster(guild_company)
|
||
|
||
|
||
func _render_roster_detail(parent: VBoxContainer, company: Dictionary, recruit_id: String) -> void:
|
||
var recruit := _recruit_by_id(company, recruit_id)
|
||
if recruit.is_empty():
|
||
parent.add_child(_label(_tr("选择一名成员查看详情。", "Select a Recruit to inspect detail."), _scaled(13), Color("#f5ead0")))
|
||
return
|
||
parent.add_child(_label(_tr("已选成员", "SELECTED RECRUIT"), _scaled(10), Color("#e8d39d")))
|
||
parent.add_child(_label(_recruit_name(recruit), _scaled(18), Color("#f0ca75")))
|
||
parent.add_child(_label(_tr("%s · 等级 %d · XP %d", "%s · Level %d · XP %d") % [_recruit_profession_name(recruit), int(recruit.level), int(recruit.xp)], _scaled(11), Color("#f5ead0")))
|
||
parent.add_child(_label(_tr("当前装备:%s", "Equipped: %s") % _recruit_item_name(recruit), _scaled(11), Color("#9ed7aa")))
|
||
var history: Dictionary = recruit.get("battle_history", {})
|
||
var history_copy := _label(_tr("参战 %d · 胜利 %d · 击倒 %d", "Entered %d · Victories %d · Knockouts %d") % [int(history.get("battles_entered", 0)), int(history.get("victories", 0)), int(history.get("knockouts", 0))], _scaled(10), Color("#aebbc2"))
|
||
history_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
history_copy.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
parent.add_child(history_copy)
|
||
|
||
|
||
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
|
||
guild_company = loaded.company.duplicate(true)
|
||
current_screen = "contract_choice"
|
||
_build_world_base(true)
|
||
var frame_safe := _framed_page_safe("ContractChoice")
|
||
frame_safe.add_child(_entry_chrome("ContractChoiceFrame", "content_scene_frame"))
|
||
var safe := _content_page_safe("ContractChoice")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "ContractChoiceLayout"
|
||
layout.add_theme_constant_override("separation", 6)
|
||
safe.add_child(layout)
|
||
var header := HBoxContainer.new()
|
||
header.custom_minimum_size = Vector2(0, 48)
|
||
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("委托板", "CONTRACT BOARD"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_tr("风镜原野 · 首次远征", "WINDGLASS VERGE · FIRST EXPEDITION"), _scaled(10), Color("#e8d39d")))
|
||
var company_status := _label(_tr("远征团 2 · 持续保留", "COMPANY 2 · PERSISTENT"), _scaled(11), Color("#73b9e7"))
|
||
company_status.name = "ContractCompanyStatus"
|
||
company_status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(company_status)
|
||
var body := HBoxContainer.new()
|
||
body.name = "ContractChoiceBody"
|
||
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 = "ContractBoardScene"
|
||
scene.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
scene.size_flags_stretch_ratio = 1.25
|
||
scene.add_theme_constant_override("separation", 2)
|
||
body.add_child(scene)
|
||
scene.add_child(_label(_tr("01 · 风镜初渡", "01 · FIRST WINDGLASS CROSSING"), _scaled(16), Color("#f5ead0")))
|
||
var route := _label(_tr("穿越悬浮风镜,建立公会的第一条安全通路。", "Cross the suspended windglass and establish the Guild's first safe route."), _scaled(11), Color("#e8d39d"))
|
||
route.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
scene.add_child(route)
|
||
scene.add_child(_contract_company_scene(guild_company.owned_recruits))
|
||
var review := VBoxContainer.new()
|
||
review.name = "ContractReview"
|
||
review.custom_minimum_size = Vector2(minf(360.0, size.x * 0.42), 0)
|
||
review.add_theme_constant_override("separation", 5)
|
||
body.add_child(review)
|
||
review.add_child(_label(_tr("选择难度", "CHOOSE DIFFICULTY"), _scaled(11), Color("#e8d39d")))
|
||
var segments := HBoxContainer.new()
|
||
segments.name = "ContractDifficultySegments"
|
||
segments.custom_minimum_size = Vector2(0, 44)
|
||
segments.add_theme_constant_override("separation", 3)
|
||
review.add_child(segments)
|
||
for difficulty in GuildContractService.DIFFICULTIES:
|
||
segments.add_child(_contract_segment_button(String(difficulty)))
|
||
var difficulty_copy := _label(_difficulty_copy(contract_difficulty), _scaled(11), Color("#f5ead0"))
|
||
difficulty_copy.name = "ContractDifficultyCopy"
|
||
difficulty_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
difficulty_copy.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
review.add_child(difficulty_copy)
|
||
var facts := _label(_tr("建立后:52 金币 · 队列容量 4\n市场:3 名招募 · 4 件装备 · 3 条战斗路线", "On creation: 52 Coin · Line capacity 4\nMarket: 3 Recruits · 4 equipment · 3 battle routes"), _scaled(11), Color("#9ed7aa"))
|
||
facts.name = "ContractCreationFacts"
|
||
facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
review.add_child(facts)
|
||
var notice := _label(contract_notice, _scaled(10), Color("#f0ca75"))
|
||
notice.name = "ContractNotice"
|
||
notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
review.add_child(notice)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "ContractChoiceFooter"
|
||
footer.custom_minimum_size = Vector2(0, 52)
|
||
footer.add_theme_constant_override("separation", 8)
|
||
layout.add_child(footer)
|
||
footer.add_child(_company_button("ContractCancel", _tr("返回公会", "Back to Guild"), back_to_guild, "company_utility", "back"))
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var confirm := _company_button("ContractConfirm", _tr("建立委托", "Create Contract"), confirm_contract_choice, "content_primary", "confirm")
|
||
confirm.custom_minimum_size = Vector2(230, 52)
|
||
confirm.tooltip_text = _tr("确认风镜初渡、当前难度、52 金币与队列容量 4", "Confirm First Windglass Crossing, current difficulty, 52 Coin and Line capacity 4")
|
||
footer.add_child(confirm)
|
||
segments.get_child(GuildContractService.DIFFICULTIES.find(contract_difficulty)).call_deferred("grab_focus")
|
||
|
||
|
||
func select_contract_difficulty(value: String) -> void:
|
||
if value not in GuildContractService.DIFFICULTIES:
|
||
return
|
||
contract_difficulty = value
|
||
contract_notice = ""
|
||
show_contract_choice()
|
||
|
||
|
||
func confirm_contract_choice() -> void:
|
||
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
|
||
current_screen = "market"
|
||
_build_world_base(true)
|
||
var frame_safe := _framed_page_safe("Market")
|
||
frame_safe.add_child(_entry_chrome("MarketFrame", "content_scene_frame"))
|
||
var safe := _content_page_safe("Market")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "MarketLayout"
|
||
layout.add_theme_constant_override("separation", 6)
|
||
safe.add_child(layout)
|
||
var contract: Dictionary = resolved.active_contract
|
||
var next_stage_market: bool = not contract.get("recovery_receipts", []).is_empty()
|
||
var short_market := size.y < 380.0
|
||
var header := HBoxContainer.new()
|
||
header.custom_minimum_size = Vector2(0, 40 if short_market else 44)
|
||
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("下一阶段市场", "NEXT-STAGE MARKET") if next_stage_market else _tr("首次市场", "FIRST MARKET"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_tr("战后归队 · 稳定货单", "POST-BATTLE RETURN · STABLE LEDGER") if next_stage_market else _tr("风镜初渡 · 稳定货单", "FIRST WINDGLASS CROSSING · STABLE LEDGER"), _scaled(10), Color("#e8d39d")))
|
||
var company_line := _label(_tr("远征团 %d | 队列容量 %d", "Company %d | Line capacity %d") % [resolved.owned_recruits.size(), int(contract.line_capacity)], _scaled(12), Color("#73b9e7"))
|
||
company_line.name = "MarketCompanyStatus"
|
||
company_line.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(company_line)
|
||
var coin := _label(_tr("%d 金币", "%d COIN") % int(contract.coin), _scaled(14), Color("#f0ca75"))
|
||
coin.name = "MarketCoinStatus"
|
||
coin.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(coin)
|
||
var body := HBoxContainer.new()
|
||
body.name = "MarketBody"
|
||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_theme_constant_override("separation", 14)
|
||
layout.add_child(body)
|
||
var ledger := VBoxContainer.new()
|
||
ledger.name = "MarketLedger"
|
||
ledger.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
ledger.size_flags_stretch_ratio = 1.35
|
||
ledger.add_theme_constant_override("separation", 4)
|
||
body.add_child(ledger)
|
||
var categories := HBoxContainer.new()
|
||
categories.name = "MarketCategories"
|
||
categories.custom_minimum_size = Vector2(0, 44)
|
||
categories.add_theme_constant_override("separation", 4)
|
||
ledger.add_child(categories)
|
||
for category in ["recruit", "equipment", "battle"]:
|
||
categories.add_child(_market_category_button(category))
|
||
var offers := VBoxContainer.new()
|
||
offers.name = "MarketOfferList"
|
||
offers.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
offers.add_theme_constant_override("separation", 3)
|
||
ledger.add_child(offers)
|
||
var rows: Array = contract.market_board.get(_market_collection_field(market_category), [])
|
||
for index in range(rows.size()):
|
||
offers.add_child(_market_offer_row(market_category, rows[index], index))
|
||
var context := VBoxContainer.new()
|
||
context.name = "MarketContext"
|
||
context.custom_minimum_size = Vector2(minf(350.0, size.x * 0.40), 0)
|
||
context.add_theme_constant_override("separation", 5)
|
||
body.add_child(context)
|
||
_render_market_context(context, contract)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "MarketFooter"
|
||
footer.custom_minimum_size = Vector2(0, 44 if short_market else 48)
|
||
footer.add_theme_constant_override("separation", 8)
|
||
layout.add_child(footer)
|
||
var back := _company_button("MarketBackGuild", _tr("返回公会", "Back to Guild"), back_to_guild, "company_utility", "back")
|
||
back.custom_minimum_size = Vector2(180, 44 if short_market else 48)
|
||
footer.add_child(back)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var title := _company_button("MarketTitle", _tr("标题首页", "Title"), back_to_title, "company_secondary", "home")
|
||
title.custom_minimum_size = Vector2(150 if short_market else 180, 44 if short_market else 48)
|
||
footer.add_child(title)
|
||
var return_contract := _company_button("MarketReturnContract", _tr("返回委托", "Return Contract"), open_contract_return_review, "company_secondary", "back")
|
||
return_contract.custom_minimum_size = Vector2(170 if short_market else 190, 44 if short_market else 48)
|
||
return_contract.tooltip_text = _tr("主动结束当前委托;金币、成员、等级、装备与历史均保留", "Voluntarily leave this Contract; Coin, members, levels, equipment and history are retained")
|
||
footer.add_child(return_contract)
|
||
var formation_available: bool = contract.hire_receipts.size() == 2 and resolved.owned_recruits.size() == int(contract.line_capacity)
|
||
var formation := _company_button(
|
||
"MarketFormation",
|
||
_tr("建立队列", "Establish Line"),
|
||
open_party_line,
|
||
"company_primary",
|
||
"confirm"
|
||
)
|
||
formation.custom_minimum_size = Vector2(160 if short_market else 190, 44 if short_market else 48)
|
||
_set_entry_button_disabled(formation, not formation_available)
|
||
formation.tooltip_text = (
|
||
_tr("四名成员已就位;进入自动编队", "Four members are ready; enter automatic formation")
|
||
if formation_available else _tr("需要完成两笔独立招募并达到远征团 4/4", "Complete both separate hires and reach Company 4/4 first")
|
||
)
|
||
footer.add_child(formation)
|
||
var focus_target := find_child("MarketOffer%s" % market_selected_offer_id, true, false) as Control
|
||
if focus_target != null:
|
||
focus_target.call_deferred("grab_focus")
|
||
else:
|
||
categories.get_child(_market_categories().find(market_category)).call_deferred("grab_focus")
|
||
|
||
|
||
func open_party_line() -> void:
|
||
show_party_line()
|
||
|
||
|
||
func show_party_line(company := {}) -> void:
|
||
formation_risk_service.company_path = initial_company_service.company_path
|
||
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
|
||
guild_company = resolved
|
||
current_screen = "party_line"
|
||
_build_party_master_base()
|
||
_build_party_master_ui(resolved, formation)
|
||
|
||
|
||
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_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),
|
||
}
|
||
current_screen = "readiness_review"
|
||
var front_recruit := _recruit_by_id(resolved, String(party_draft_order[0]))
|
||
var coverage: Dictionary = formation.coverage
|
||
_build_readiness_master_base()
|
||
_build_readiness_master_ui(resolved, formation, front_recruit, coverage)
|
||
|
||
|
||
func _build_readiness_master_base() -> void:
|
||
for child in get_children():
|
||
child.free()
|
||
var matte := ColorRect.new()
|
||
matte.name = "ReadinessMasterMatte"
|
||
matte.color = Color("#17252c")
|
||
matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
matte.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(matte)
|
||
var canvas := Control.new()
|
||
canvas.name = "ReadinessMasterCanvas"
|
||
canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(canvas)
|
||
readiness_master_design_canvas = canvas
|
||
_layout_readiness_master_canvas()
|
||
var master := TextureRect.new()
|
||
master.name = "ReadinessStaticMaster"
|
||
var source := Image.load_from_file(ProjectSettings.globalize_path(READINESS_STATIC_MASTER_PATH))
|
||
master.texture = ImageTexture.create_from_image(source)
|
||
master.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
master.stretch_mode = TextureRect.STRETCH_SCALE
|
||
master.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
master.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
canvas.add_child(master)
|
||
|
||
|
||
func _layout_readiness_master_canvas() -> void:
|
||
if not is_instance_valid(readiness_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)
|
||
readiness_master_design_canvas.position = (available - canvas_size) * 0.5
|
||
readiness_master_design_canvas.size = canvas_size
|
||
|
||
|
||
func _build_readiness_master_ui(resolved: Dictionary, formation: Dictionary, front_recruit: Dictionary, coverage: Dictionary) -> void:
|
||
var canvas := readiness_master_design_canvas
|
||
if not is_instance_valid(canvas):
|
||
return
|
||
var overlay := Control.new()
|
||
overlay.name = "ReadinessMasterOverlay"
|
||
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
overlay.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
canvas.add_child(overlay)
|
||
_readiness_master_label(overlay, "ReadinessTitle", Rect2(0.19, 0.035, 0.10, 0.055), _tr("准备", "READY"), 9, Color("#242c2b"))
|
||
_readiness_master_label(overlay, "ReadinessSnapshot", Rect2(0.285, 0.035, 0.075, 0.055), _tr("冻结", "FROZEN"), 8, Color("#514331"))
|
||
_readiness_master_label(overlay, "ReadinessContract", Rect2(0.40, 0.035, 0.14, 0.055), String(resolved.active_contract.get("contract_id", "CTR-R1-01")), 8, Color("#514331"))
|
||
_readiness_master_label(overlay, "ReadinessPressure", Rect2(0.61, 0.035, 0.13, 0.055), _party_pressure_short(String(resolved.active_contract.get("difficulty", "standard"))), 8, Color("#514331"))
|
||
_readiness_master_label(overlay, "ReadinessCompany", Rect2(0.82, 0.035, 0.12, 0.055), "%d/4" % resolved.owned_recruits.size(), 8, Color("#514331"))
|
||
var settings_button := _readiness_master_action_button("ReadinessSettings", Rect2(0.88, 0.0, 0.10, 0.14), _readiness_open_settings, _tr("打开设置", "Open settings"))
|
||
overlay.add_child(settings_button)
|
||
|
||
var cord_track := PartyLineTrack.new()
|
||
cord_track.name = "ReadinessFormationCord"
|
||
cord_track.render_layer = PartyLineTrack.RenderLayer.CORD
|
||
cord_track.z_index = 25
|
||
cord_track.set_meta("render_layer", "cord")
|
||
_party_master_place(cord_track, Rect2(0.08, 0.30, 0.80, 0.37))
|
||
cord_track.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(cord_track)
|
||
var member_areas := [
|
||
Rect2(0.61, 0.29, 0.15, 0.30),
|
||
Rect2(0.46, 0.30, 0.14, 0.29),
|
||
Rect2(0.31, 0.30, 0.14, 0.29),
|
||
Rect2(0.16, 0.30, 0.14, 0.29),
|
||
]
|
||
var member_nodes: Array[Control] = []
|
||
for visual_index in range(party_draft_order.size() - 1, -1, -1):
|
||
var recruit_id := String(party_draft_order[visual_index])
|
||
var recruit := _recruit_by_id(resolved, recruit_id)
|
||
var button := _readiness_master_member_button(visual_index, recruit, member_areas[visual_index])
|
||
button.z_index = 30
|
||
overlay.add_child(button)
|
||
member_nodes.append(button)
|
||
var identity := _readiness_master_label(overlay, "ReadinessMember%dIdentity" % visual_index, Rect2(0.19 + float(3 - visual_index) * 0.15, 0.595, 0.12, 0.045), _recruit_name(recruit), 8, Color("#2b241b"))
|
||
identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
var effects_track := PartyLineTrack.new()
|
||
effects_track.name = "ReadinessFormationEffects"
|
||
effects_track.render_layer = PartyLineTrack.RenderLayer.EFFECTS
|
||
effects_track.z_index = 35
|
||
effects_track.set_meta("render_layer", "effects")
|
||
_party_master_place(effects_track, Rect2(0.08, 0.30, 0.80, 0.37))
|
||
effects_track.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
effects_track.bind(member_nodes, "", String(settings.get("locale", "zh_CN")))
|
||
overlay.add_child(effects_track)
|
||
cord_track.bind(member_nodes, "", String(settings.get("locale", "zh_CN")))
|
||
|
||
var summary_band := Panel.new()
|
||
summary_band.name = "ReadinessSummaryBand"
|
||
_readiness_master_place(summary_band, Rect2(0.04, 0.645, 0.73, 0.19))
|
||
var summary_style := StyleBoxFlat.new()
|
||
summary_style.bg_color = Color("#f2e4c5", 0.90)
|
||
summary_style.border_color = Color("#a27e3c", 0.86)
|
||
summary_style.set_border_width_all(2)
|
||
summary_style.set_corner_radius_all(7)
|
||
summary_band.add_theme_stylebox_override("panel", summary_style)
|
||
summary_band.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
summary_band.z_index = 20
|
||
overlay.add_child(summary_band)
|
||
var summary := _readiness_master_label(
|
||
overlay,
|
||
"ReadinessFrozenFacts",
|
||
Rect2(0.06, 0.66, 0.69, 0.055),
|
||
_tr("0 号首要目标:%s · 四人同时出战 · 无自动替补", "Position 0 primary exposure: %s · all four deploy · no automatic Reserve") % _recruit_name(front_recruit),
|
||
9,
|
||
Color("#2b241b")
|
||
)
|
||
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
summary.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_readiness_master_label(overlay, "ReadinessProtection", Rect2(0.09, 0.735, 0.14, 0.055), _tr("保护 %d", "GUARD %d") % int(coverage.get("protection", 0)), 8, Color("#2b241b"))
|
||
_readiness_master_label(overlay, "ReadinessReach", Rect2(0.28, 0.735, 0.14, 0.055), _tr("射程 %d", "REACH %d") % int(coverage.get("long_reach", 0)), 8, Color("#2b241b"))
|
||
_readiness_master_label(overlay, "ReadinessPressureCoverage", Rect2(0.47, 0.735, 0.16, 0.055), _tr("压力 %d", "PRESSURE %d") % int(coverage.get("control", 0)), 8, Color("#2b241b"))
|
||
_readiness_master_label(overlay, "ReadinessReserve", Rect2(0.67, 0.735, 0.13, 0.055), _tr("预备 0", "RESERVE 0"), 8, Color("#2b241b"))
|
||
|
||
var warning_button := _readiness_warning_button(formation)
|
||
_readiness_master_place(warning_button, Rect2(0.78, 0.56, 0.21, 0.23))
|
||
warning_button.z_index = 45
|
||
overlay.add_child(warning_button)
|
||
var knockout := _readiness_master_label(overlay, "ReadinessKnockoutRule", Rect2(0.78, 0.42, 0.21, 0.07), _tr("击倒后下关归队", "KNOCKOUT RETURNS NEXT STAGE"), 7, Color("#f5ead0"))
|
||
knockout.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
if String(formation.get("status", "draft")) == "ready":
|
||
var receipt := _readiness_master_label(overlay, "ReadinessReceipt", Rect2(0.78, 0.49, 0.21, 0.06), _tr("回执已保存", "RECEIPT SAVED"), 7, Color("#9ed7aa"))
|
||
receipt.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
var risk_hint := _readiness_master_label(
|
||
overlay,
|
||
"ReadinessRiskUnavailable",
|
||
Rect2(0.78, 0.27, 0.21, 0.22),
|
||
_tr("接受后\n比较风险", "ACCEPT\nTHEN RISK"),
|
||
8,
|
||
Color("#f0ca75") if String(formation.get("status", "draft")) != "ready" else Color("#9ed7aa")
|
||
)
|
||
risk_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
risk_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
risk_hint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
|
||
var revise := _readiness_master_command_button("ReadinessRevise", READINESS_COMMAND_REVISE, _tr("修改队列", "REVISE"), revise_readiness, Rect2(0.14, 0.85, 0.17, 0.14), false, _tr("返回队列并重新保存", "Return to the Party Line and save again"), false)
|
||
var back := _readiness_master_command_button("ReadinessBack", READINESS_COMMAND_BACK, _tr("返回公会", "BACK"), cancel_party_line, Rect2(0.29, 0.85, 0.15, 0.14), false, _tr("返回公会,不改变已保存顺序", "Return to Guild without changing the saved order"), false)
|
||
var accept := _readiness_master_command_button("ReadinessAccept", READINESS_COMMAND_ACCEPT, _tr("接受准备", "ACCEPT"), accept_readiness, Rect2(0.44, 0.85, 0.29, 0.14), String(formation.get("status", "draft")) == "ready", _tr("冻结四人顺序;不会选择风险或开始战斗", "Freeze the four-member order; no risk or battle starts"), true)
|
||
var risk := _readiness_master_command_button("ReadinessRisk", READINESS_COMMAND_RISK, _tr("比较风险", "RISK"), open_risk_board, Rect2(0.75, 0.85, 0.21, 0.14), String(formation.get("status", "draft")) != "ready", _tr("接受准备后比较三个风险选项", "Compare three risk options after preparation is accepted"), false)
|
||
for command in [revise, back, accept, risk]:
|
||
command.z_index = 50
|
||
overlay.add_child(command)
|
||
var focus_nodes: Array[Control] = [settings_button]
|
||
focus_nodes.append_array(member_nodes)
|
||
focus_nodes.append(warning_button)
|
||
focus_nodes.append(revise)
|
||
focus_nodes.append(back)
|
||
focus_nodes.append(accept)
|
||
focus_nodes.append(risk)
|
||
for index in range(focus_nodes.size()):
|
||
var current := focus_nodes[index]
|
||
var next := focus_nodes[(index + 1) % focus_nodes.size()]
|
||
var previous := focus_nodes[(index - 1 + focus_nodes.size()) % focus_nodes.size()]
|
||
current.focus_next = current.get_path_to(next)
|
||
current.focus_previous = current.get_path_to(previous)
|
||
var initial_focus: Control = accept if not accept.disabled else revise
|
||
initial_focus.call_deferred("grab_focus")
|
||
|
||
|
||
func _readiness_master_label(parent: Control, node_name: String, area: Rect2, text_value: String, font_size: int, color: Color) -> Label:
|
||
var label := _label(text_value, _readiness_master_text_size(font_size), color)
|
||
label.name = node_name
|
||
label.set_meta("design_font_size", font_size)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
if node_name in ["ReadinessFrozenFacts", "ReadinessProtection", "ReadinessReach", "ReadinessPressureCoverage", "ReadinessReserve"]:
|
||
label.z_index = 21
|
||
_party_master_place(label, area)
|
||
parent.add_child(label)
|
||
return label
|
||
|
||
|
||
func _readiness_master_text_size(design_size: int) -> int:
|
||
var canvas_height := readiness_master_design_canvas.size.y if is_instance_valid(readiness_master_design_canvas) else size.y
|
||
var canvas_scale := clampf(canvas_height / 360.0, 1.0, 2.0)
|
||
var text_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
return mini(16, maxi(design_size, int(round(float(design_size) * canvas_scale * text_scale))))
|
||
|
||
|
||
func _readiness_master_place(control: Control, area: Rect2) -> void:
|
||
_party_master_place(control, area)
|
||
|
||
|
||
func _readiness_master_action_button(node_name: String, area: Rect2, callback: Callable, tooltip: String) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.tooltip_text = tooltip
|
||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
_party_master_place(button, area)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _readiness_master_member_button(position: int, recruit: Dictionary, area: Rect2) -> Button:
|
||
var recruit_id := String(recruit.get("recruit_id", ""))
|
||
var button := _readiness_master_action_button("ReadinessPosition%d" % position, area, _readiness_review_member.bind(recruit_id), _tr("查看 %s 的准备事实", "Review %s readiness facts") % _recruit_name(recruit))
|
||
button.set_meta("recruit_id", recruit_id)
|
||
button.set_meta("position", position)
|
||
button.clip_contents = false
|
||
button.add_theme_stylebox_override("focus", _flat_style(Color(0.95, 0.82, 0.34, 0.11), Color("#d6ad5d"), 2, 8))
|
||
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.20
|
||
portrait.anchor_top = -0.22
|
||
portrait.anchor_right = 1.20
|
||
portrait.anchor_bottom = 0.93
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(portrait)
|
||
return button
|
||
|
||
|
||
func _readiness_warning_button(formation: Dictionary) -> Button:
|
||
var button := Button.new()
|
||
button.name = "ReadinessWarning"
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.tooltip_text = _tr("查看允许的警告与归属", "Review allowed warnings and ownership")
|
||
for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
button.pressed.connect(_readiness_review_warnings, CONNECT_DEFERRED)
|
||
var lines: Array[String] = []
|
||
var coverage: 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"))
|
||
for index in range(3):
|
||
var row := Control.new()
|
||
row.name = "ReadinessWarningRow%d" % index
|
||
row.anchor_left = 0.05
|
||
row.anchor_right = 0.95
|
||
row.anchor_top = 0.02 + float(index) * 0.32
|
||
row.anchor_bottom = 0.30 + float(index) * 0.32
|
||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(row)
|
||
var accent := ColorRect.new()
|
||
accent.anchor_right = 0.025
|
||
accent.anchor_bottom = 1.0
|
||
accent.color = [Color("#b7584d"), Color("#d6ad5d"), Color("#4b8790")][index]
|
||
accent.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_child(accent)
|
||
var label := _label(lines[index], _readiness_master_text_size(8), Color("#514331"))
|
||
label.name = "WarningLabel"
|
||
label.anchor_left = 0.07
|
||
label.anchor_right = 1.0
|
||
label.anchor_bottom = 1.0
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_child(label)
|
||
return button
|
||
|
||
|
||
func _readiness_master_command_button(node_name: String, texture: Texture2D, text_value: String, callback: Callable, area: Rect2, disabled: bool, tooltip: String, primary: bool) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.disabled = disabled
|
||
button.tooltip_text = tooltip
|
||
button.mouse_default_cursor_shape = Control.CURSOR_FORBIDDEN if disabled else Control.CURSOR_POINTING_HAND
|
||
var normal := _flat_style(Color("#17323d", 1.0) if not primary else Color("#3d6934", 1.0), Color("#d6ad5d", 0.92), 2, 7)
|
||
var hover := _flat_style(Color("#295264", 1.0) if not primary else Color("#4d8341", 1.0), Color("#f3d991"), 2, 7)
|
||
var disabled_style := _flat_style(Color("#38454a", 0.72), Color("#788187", 0.60), 2, 7)
|
||
button.add_theme_stylebox_override("normal", normal)
|
||
button.add_theme_stylebox_override("hover", hover)
|
||
button.add_theme_stylebox_override("pressed", hover)
|
||
button.add_theme_stylebox_override("focus", hover)
|
||
button.add_theme_stylebox_override("hover_pressed", hover)
|
||
button.add_theme_stylebox_override("disabled", disabled_style)
|
||
_party_master_place(button, area)
|
||
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.04
|
||
icon.anchor_top = 0.06
|
||
icon.anchor_right = 0.34
|
||
icon.anchor_bottom = 0.94
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(icon)
|
||
var label := _button_label("ButtonLabel", text_value, _readiness_master_text_size(10 if primary else 9), Color("#f5ead0") if not disabled else Color("#a7afb0"))
|
||
label.anchor_left = 0.31
|
||
label.anchor_top = 0.10
|
||
label.anchor_right = 0.98
|
||
label.anchor_bottom = 0.92
|
||
label.offset_left = 0
|
||
label.offset_top = 0
|
||
label.offset_right = 0
|
||
label.offset_bottom = 0
|
||
label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
button.add_child(label)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
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
|
||
guild_company = resolved
|
||
current_screen = "risk_board"
|
||
_build_risk_board_master_base()
|
||
_build_risk_board_master_ui(resolved, formation)
|
||
|
||
|
||
func _build_risk_board_master_base() -> void:
|
||
for child in get_children():
|
||
child.free()
|
||
var matte := ColorRect.new()
|
||
matte.name = "RiskBoardMatte"
|
||
matte.color = Color("#16252d")
|
||
matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
matte.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(matte)
|
||
risk_board_master_design_canvas = Control.new()
|
||
risk_board_master_design_canvas.name = "RiskBoardMasterCanvas"
|
||
risk_board_master_design_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(risk_board_master_design_canvas)
|
||
_layout_risk_board_master_canvas()
|
||
var master := TextureRect.new()
|
||
master.name = "RiskBoardStaticMaster"
|
||
master.texture = RISK_BOARD_STATIC_MASTER
|
||
master.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
master.stretch_mode = TextureRect.STRETCH_SCALE
|
||
master.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
master.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
risk_board_master_design_canvas.add_child(master)
|
||
|
||
|
||
func _layout_risk_board_master_canvas() -> void:
|
||
if not is_instance_valid(risk_board_master_design_canvas):
|
||
return
|
||
var available := size
|
||
if available.x <= 0.0 or available.y <= 0.0:
|
||
return
|
||
var canvas_size := Vector2(available.y * 16.0 / 9.0, available.y) if available.x / available.y >= 16.0 / 9.0 else Vector2(available.x, available.x * 9.0 / 16.0)
|
||
risk_board_master_design_canvas.position = (available - canvas_size) * 0.5
|
||
risk_board_master_design_canvas.size = canvas_size
|
||
|
||
|
||
func _risk_master_place(control: Control, area: Rect2) -> void:
|
||
_party_master_place(control, area)
|
||
|
||
|
||
func _risk_master_label(parent: Control, node_name: String, area: Rect2, value: String, font_size: int, color: Color, align := HORIZONTAL_ALIGNMENT_LEFT) -> Label:
|
||
var label := _label(value, _risk_master_text_size(font_size), color)
|
||
label.name = node_name
|
||
label.horizontal_alignment = align
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_risk_master_place(label, area)
|
||
parent.add_child(label)
|
||
return label
|
||
|
||
|
||
func _risk_master_text_size(design_size: int) -> int:
|
||
var canvas_height := risk_board_master_design_canvas.size.y if is_instance_valid(risk_board_master_design_canvas) else size.y
|
||
var scale := clampf(canvas_height / 360.0, 1.0, 2.0) * float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
return mini(18, maxi(design_size, int(round(float(design_size) * scale))))
|
||
|
||
|
||
func _risk_master_action_button(node_name: String, area: Rect2, callback: Callable, tooltip: String) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = false
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.tooltip_text = tooltip
|
||
button.text = tooltip
|
||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
button.add_theme_font_override("font", UI_FONT)
|
||
button.add_theme_font_size_override("font_size", _risk_master_text_size(9))
|
||
button.add_theme_color_override("font_color", Color("#f5ead0"))
|
||
button.add_theme_color_override("font_hover_color", Color("#fff1ba"))
|
||
button.add_theme_stylebox_override("normal", _flat_style(Color("#17323d", 0.95), Color("#d6ad5d", 0.9), 2, 8))
|
||
button.add_theme_stylebox_override("hover", _flat_style(Color("#295264", 0.98), Color("#f3d991"), 2, 8))
|
||
button.add_theme_stylebox_override("pressed", _flat_style(Color("#4b6f50", 0.98), Color("#f3d991"), 2, 8))
|
||
_risk_master_place(button, area)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _risk_open_settings() -> void:
|
||
settings_caller = "risk_board"
|
||
show_settings()
|
||
|
||
|
||
func _build_risk_board_master_ui(resolved: Dictionary, formation: Dictionary) -> void:
|
||
var canvas := risk_board_master_design_canvas
|
||
var overlay := Control.new()
|
||
overlay.name = "RiskBoardMasterOverlay"
|
||
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
overlay.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
canvas.add_child(overlay)
|
||
_risk_master_label(overlay, "RiskTitle", Rect2(0.07, 0.035, 0.19, 0.045), _tr("RSK-001 风险委员会", "RSK-001 RISK BOARD"), 9, Color("#f5ead0"))
|
||
_risk_master_label(overlay, "RiskSubtitle", Rect2(0.07, 0.082, 0.19, 0.035), _tr("风险比较", "COMPARE RISK"), 7, Color("#e8d39d"))
|
||
_risk_master_label(overlay, "RiskSnapshot", Rect2(0.28, 0.03, 0.30, 0.05), _tr("队伍快照 · 四人已冻结", "PARTY SNAPSHOT · FOUR FROZEN"), 10, Color("#2b241b"))
|
||
var party_ids: Array = Array(formation.get("accepted_party_snapshot", {}).get("front_to_rear_ids", formation.get("saved_front_to_rear_ids", [])))
|
||
for visual_index in range(party_ids.size()):
|
||
var recruit := _recruit_by_id(resolved, String(party_ids[party_ids.size() - 1 - visual_index]))
|
||
var actor := TextureRect.new()
|
||
actor.name = "RiskPartyActor%d" % visual_index
|
||
actor.texture = InitialCompanyArt.texture_for(recruit)
|
||
actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
actor.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
actor.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
_risk_master_place(actor, Rect2(0.28 + float(visual_index) * 0.13, 0.055, 0.10, 0.13))
|
||
actor.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(actor)
|
||
_risk_master_label(overlay, "RiskPartyPosition%d" % visual_index, Rect2(0.365 + float(visual_index) * 0.13, 0.075, 0.075, 0.045), _tr("位置 %d", "POS %d") % (party_ids.size() - 1 - visual_index), 7, Color("#2b241b"))
|
||
var options: Array = formation.get("risk_options", [])
|
||
var columns := [Rect2(0.085, 0.22, 0.265, 0.57), Rect2(0.367, 0.22, 0.265, 0.57), Rect2(0.649, 0.22, 0.265, 0.57)]
|
||
var focus_nodes: Array[Control] = []
|
||
for index in range(mini(options.size(), 3)):
|
||
var option: Dictionary = options[index]
|
||
var button := _risk_master_option_button(option, columns[index])
|
||
overlay.add_child(button)
|
||
focus_nodes.append(button)
|
||
var back := _risk_master_action_button("RiskBackReadiness", Rect2(0.03, 0.84, 0.18, 0.14), back_to_guild, _tr("返回公会", "Back to Guild"))
|
||
var review := _risk_master_action_button("RiskReview", Rect2(0.37, 0.84, 0.26, 0.14), open_risk_commitment, _tr("检查已选风险", "Review selected risk"))
|
||
review.disabled = risk_selected_id.is_empty()
|
||
review.add_theme_stylebox_override("disabled", _flat_style(Color("#51575b", 0.92), Color("#8b8e8e"), 2, 8))
|
||
var settings_button := _risk_master_action_button("RiskSettings", Rect2(0.79, 0.84, 0.18, 0.14), _risk_open_settings, _tr("打开设置", "Open settings"))
|
||
overlay.add_child(back)
|
||
overlay.add_child(review)
|
||
overlay.add_child(settings_button)
|
||
# Keep the selection explanation in the clear strip between the option band
|
||
# and command rail. The authored rail begins at 0.84, so the helper must not
|
||
# be anchored below it where it can be clipped or hidden by the buttons.
|
||
var no_selection := _risk_master_label(overlay, "RiskNoSelection", Rect2(0.37, 0.785, 0.26, 0.036), _tr("请选择一个风险以查看详情", "SELECT ONE RISK TO ENABLE REVIEW"), 7, Color("#2b241b"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
no_selection.visible = risk_selected_id.is_empty()
|
||
var selected_detail := _risk_master_label(overlay, "RiskSelectedDetail", Rect2(0.37, 0.785, 0.26, 0.036), "", 7, Color("#3d7449"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
selected_detail.visible = not risk_selected_id.is_empty()
|
||
if not risk_selected_id.is_empty():
|
||
selected_detail.text = _tr("已选:%s · 再次点击可更换", "Selected: %s · click another to change") % _risk_title(_risk_option_by_id(formation, risk_selected_id))
|
||
# The option rows already carry the sourced coverage facts. Retain this
|
||
# semantic node for accessibility/state inspection, but do not paint a
|
||
# redundant summary over the command rail at compact landscape sizes.
|
||
var coverage := _risk_master_label(overlay, "RiskCoverage", Rect2(0.38, 0.785, 0.24, 0.036), _tr("覆盖:保护 %d · 射程 %d · 续航 %d · 控制 %d", "Coverage: Guard %d · Reach %d · Sustain %d · Control %d") % [int(formation.coverage.get("protection", 0)), int(formation.coverage.get("long_reach", 0)), int(formation.coverage.get("sustain", 0)), int(formation.coverage.get("control", 0))], 7, Color("#2b241b"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
coverage.visible = false
|
||
coverage.set_meta("semantic_node_retained", true)
|
||
focus_nodes.append(back)
|
||
focus_nodes.append(review)
|
||
focus_nodes.append(settings_button)
|
||
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()])
|
||
(focus_nodes[0] if focus_nodes.size() > 0 else review).call_deferred("grab_focus")
|
||
|
||
|
||
func _risk_master_option_button(option: Dictionary, area: Rect2) -> Button:
|
||
var option_id := String(option.get("risk_option_id", ""))
|
||
var button := _risk_master_action_button("RiskOption%s" % option_id, area, select_risk_option.bind(option_id), _risk_title(option))
|
||
button.text = ""
|
||
button.set_meta("selected", option_id == risk_selected_id)
|
||
button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||
button.add_theme_stylebox_override("hover", _flat_style(Color("#f2ca75", 0.12), Color(0, 0, 0, 0), 0, 0))
|
||
button.add_theme_stylebox_override("pressed", _flat_style(Color("#5d855e", 0.16), Color(0, 0, 0, 0), 0, 0))
|
||
# The clean master intentionally leaves the option interiors neutral. Cover
|
||
# that region with an opaque warm-paper surface so the authored panel frame
|
||
# remains visible while no baked semantic glyphs can leak through.
|
||
var paper := ColorRect.new()
|
||
paper.name = "RiskOptionPaper"
|
||
paper.color = Color("#f2eadc")
|
||
# Draw the paper below the Button's theme so hover/pressed/focus states
|
||
# remain visible while the surface still sits above the static master.
|
||
paper.show_behind_parent = true
|
||
paper.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_risk_master_place(paper, Rect2(-0.02, 0.14, 1.02, 0.84))
|
||
button.add_child(paper)
|
||
var selection := ColorRect.new()
|
||
selection.name = "RiskSelectionHighlight"
|
||
selection.color = Color("#d6ad5d", 0.18)
|
||
selection.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
selection.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
selection.visible = option_id == risk_selected_id
|
||
button.add_child(selection)
|
||
var letter := _risk_master_label(button, "RiskOptionLetter", Rect2(0.05, 0.02, 0.16, 0.12), ["A", "B", "C"][int(option.get("risk", 0))], 16, Color("#263546"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
letter.add_theme_color_override("font_shadow_color", Color("#f4e6be"))
|
||
_risk_master_label(button, "RiskOptionTitle", Rect2(0.22, 0.035, 0.73, 0.08), _risk_title(option), 11, Color("#2b241b"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
var lines := [
|
||
_tr("威胁 %d人 · %d波", "THREAT %d · %d WAVES") % [int(option.known_threat.enemy_count), int(option.known_threat.wave_count)],
|
||
_tr("信息 %s", "INFO %s") % _risk_uncertainty_short(String(option.uncertainty_id)),
|
||
_tr("成本 %d金", "COST %d COIN") % int(option.entry_cost),
|
||
_tr("奖励 %s", "REWARD %s") % _risk_reward_short(String(option.reward_class_id)),
|
||
_tr("恢复 %s", "RECOVERY %s") % _recovery_name(String(option.recovery_exposure_id)),
|
||
_tr("覆盖 %d/4", "COVERAGE %d/4") % int(option.get("risk", 0) + 2),
|
||
]
|
||
var fact_symbols := ["!", "?", "¢", "◆", "!", "●"]
|
||
var fact_colors := [Color("#a74337"), Color("#5d3f91"), Color("#a07816"), Color("#3d7449"), Color("#a44a2e"), Color("#2c67a3")]
|
||
for index in range(lines.size()):
|
||
var icon := Panel.new()
|
||
icon.name = "RiskFactIcon%d" % index
|
||
var icon_style := StyleBoxFlat.new()
|
||
icon_style.bg_color = fact_colors[index]
|
||
icon_style.border_color = Color("#f1e6c9")
|
||
icon_style.set_border_width_all(1)
|
||
icon_style.set_corner_radius_all(12)
|
||
icon.add_theme_stylebox_override("panel", icon_style)
|
||
_risk_master_place(icon, Rect2(0.045, 0.19 + float(index) * 0.125, 0.09, 0.085))
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(icon)
|
||
var glyph := _risk_master_label(icon, "Glyph", Rect2(0, 0, 1, 1), fact_symbols[index], 8, Color("#fff7dc"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_risk_master_label(button, "RiskOptionFact%d" % index, Rect2(0.16, 0.18 + float(index) * 0.125, 0.78, 0.105), lines[index], 8, Color("#2b241b"))
|
||
return button
|
||
|
||
|
||
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 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
|
||
_build_risk_commitment_master_base()
|
||
_build_risk_commitment_master_ui(resolved, formation, option)
|
||
|
||
|
||
func _build_risk_commitment_master_base() -> void:
|
||
for child in get_children():
|
||
child.free()
|
||
var matte := ColorRect.new()
|
||
matte.name = "RiskCommitmentMatte"
|
||
matte.color = Color("#16252d")
|
||
matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
matte.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(matte)
|
||
risk_commitment_master_design_canvas = Control.new()
|
||
risk_commitment_master_design_canvas.name = "RiskCommitmentMasterCanvas"
|
||
risk_commitment_master_design_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(risk_commitment_master_design_canvas)
|
||
_layout_risk_commitment_master_canvas()
|
||
var master := TextureRect.new()
|
||
master.name = "RiskCommitmentStaticMaster"
|
||
master.texture = RISK_COMMITMENT_STATIC_MASTER
|
||
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
|
||
risk_commitment_master_design_canvas.add_child(master)
|
||
|
||
|
||
func _layout_risk_commitment_master_canvas() -> void:
|
||
if not is_instance_valid(risk_commitment_master_design_canvas):
|
||
return
|
||
var available := size
|
||
if available.x <= 0.0 or available.y <= 0.0:
|
||
return
|
||
var canvas_size := Vector2(available.y * 16.0 / 9.0, available.y) if available.x / available.y >= 16.0 / 9.0 else Vector2(available.x, available.x * 9.0 / 16.0)
|
||
risk_commitment_master_design_canvas.position = (available - canvas_size) * 0.5
|
||
risk_commitment_master_design_canvas.size = canvas_size
|
||
|
||
|
||
func _risk_commitment_text_size(design_size: int) -> int:
|
||
var canvas_height := risk_commitment_master_design_canvas.size.y if is_instance_valid(risk_commitment_master_design_canvas) else size.y
|
||
var canvas_scale := clampf(canvas_height / 360.0, 1.0, 2.0)
|
||
var text_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
return mini(17, maxi(7, int(round(float(design_size) * lerpf(1.0, canvas_scale, 0.35) * text_scale))))
|
||
|
||
|
||
func _risk_commitment_place(control: Control, area: Rect2) -> void:
|
||
_party_master_place(control, area)
|
||
|
||
|
||
func _risk_commitment_label(parent: Control, node_name: String, area: Rect2, value: String, font_size: int, color: Color, align := HORIZONTAL_ALIGNMENT_LEFT) -> Label:
|
||
var label := _label(value, _risk_commitment_text_size(font_size), color)
|
||
label.name = node_name
|
||
label.horizontal_alignment = align
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_risk_commitment_place(label, area)
|
||
parent.add_child(label)
|
||
return label
|
||
|
||
|
||
func _risk_commitment_hit_button(node_name: String, area: Rect2, callback: Callable, tooltip: String, disabled := false) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.tooltip_text = tooltip
|
||
button.disabled = disabled
|
||
button.mouse_default_cursor_shape = Control.CURSOR_FORBIDDEN if disabled else Control.CURSOR_POINTING_HAND
|
||
for style_name in ["normal", "disabled"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
button.add_theme_stylebox_override("hover", _flat_style(Color(0.95, 0.82, 0.35, 0.08), Color(0.95, 0.82, 0.35, 0.45), 1, 5))
|
||
button.add_theme_stylebox_override("pressed", _flat_style(Color(0.28, 0.53, 0.38, 0.16), Color(0.95, 0.82, 0.35, 0.62), 2, 5))
|
||
button.add_theme_stylebox_override("focus", _flat_style(Color(0.22, 0.45, 0.57, 0.13), Color(0.55, 0.84, 0.94, 0.92), 2, 5))
|
||
_risk_commitment_place(button, area)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _risk_commitment_command_button(node_name: String, area: Rect2, text_value: String, glyph: String, callback: Callable, primary := false, disabled := false) -> Button:
|
||
var button := _risk_commitment_hit_button(node_name, area, callback, text_value, disabled)
|
||
button.add_theme_stylebox_override("hover", _flat_style(Color(0.95, 0.82, 0.35, 0.12), Color("#f3d991"), 2, 6))
|
||
button.add_theme_stylebox_override("pressed", _flat_style(Color(0.28, 0.53, 0.38, 0.24), Color("#f3d991"), 2, 6))
|
||
button.add_theme_stylebox_override("disabled", _flat_style(Color(0.12, 0.16, 0.18, 0.22), Color(0.48, 0.50, 0.48, 0.34), 1, 6))
|
||
var icon := _risk_commitment_label(button, "CommandGlyph", Rect2(0.06, 0.18, 0.22, 0.64), glyph, 15 if primary else 12, Color("#f3d991") if not disabled else Color("#89939a"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
icon.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.65))
|
||
icon.add_theme_constant_override("shadow_offset_x", 1)
|
||
icon.add_theme_constant_override("shadow_offset_y", 1)
|
||
var label := _risk_commitment_label(button, "ButtonLabel", Rect2(0.27, 0.16, 0.68, 0.68), text_value, 10 if primary else 8, Color("#fff4d2") if not disabled else Color("#89939a"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
if text_value.is_empty():
|
||
icon.anchor_left = 0.12
|
||
icon.anchor_top = 0.08
|
||
icon.anchor_right = 0.88
|
||
icon.anchor_bottom = 0.92
|
||
label.visible = false
|
||
button.set_meta("risk_commitment_primary", primary)
|
||
return button
|
||
|
||
|
||
func _risk_commitment_marker(parent: Control, node_name: String, area: Rect2, glyph: String, color: Color, text_value := "") -> Control:
|
||
var marker := Control.new()
|
||
marker.name = node_name
|
||
marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_risk_commitment_place(marker, area)
|
||
var seal := Panel.new()
|
||
seal.name = "MarkerSeal"
|
||
seal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = color
|
||
style.border_color = Color("#f5e8c4")
|
||
style.set_border_width_all(1)
|
||
style.set_corner_radius_all(10)
|
||
seal.add_theme_stylebox_override("panel", style)
|
||
marker.add_child(seal)
|
||
_risk_commitment_label(marker, "MarkerGlyph", Rect2(0.0, 0.0, 1.0, 1.0), glyph, 8, Color("#fff7dc"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
if not text_value.is_empty():
|
||
var text := _risk_commitment_label(marker, "MarkerText", Rect2(1.05, 0.0, 3.8, 1.0), text_value, 7, Color("#3a3025"))
|
||
text.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
parent.add_child(marker)
|
||
return marker
|
||
|
||
|
||
func _build_risk_commitment_master_ui(resolved: Dictionary, formation: Dictionary, option: Dictionary) -> void:
|
||
var canvas := risk_commitment_master_design_canvas
|
||
var overlay := Control.new()
|
||
overlay.name = "RiskCommitmentMasterOverlay"
|
||
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
overlay.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
canvas.add_child(overlay)
|
||
var contract: Dictionary = resolved.active_contract
|
||
var snapshot: Dictionary = formation.get("accepted_party_snapshot", {})
|
||
var party_ids: Array = Array(snapshot.get("front_to_rear_ids", formation.get("saved_front_to_rear_ids", [])))
|
||
var dependency := _risk_commitment_dependency_status(resolved, formation, option)
|
||
var ready := risk_commitment_acknowledged and bool(dependency.get("ok", false))
|
||
|
||
_risk_commitment_label(overlay, "RiskCommitmentPageTitle", Rect2(0.08, 0.018, 0.27, 0.052), _tr("风险承诺", "RISK COMMITMENT"), 12, Color("#fff1c2"))
|
||
_risk_commitment_label(overlay, "RiskCommitmentHeaderRule", Rect2(0.08, 0.068, 0.30, 0.032), _tr("在进入战斗前核对一条已选路线。", "VERIFY ONE SELECTED ROUTE BEFORE BATTLE."), 7, Color("#e8d39d"))
|
||
var settings_button := _risk_commitment_command_button("RiskCommitmentSettings", Rect2(0.90, 0.0, 0.10, 0.14), "", "⚙", _risk_commitment_open_settings, false)
|
||
settings_button.tooltip_text = _tr("打开设置", "Open settings")
|
||
overlay.add_child(settings_button)
|
||
|
||
# The left relic is a single selected-risk surface. Values are short, sourced
|
||
# facts so the painted parchment remains visible and never becomes a card wall.
|
||
var risk_glyphs := ["◆", "▲", "✦"]
|
||
_risk_commitment_marker(overlay, "RiskIdentityMarker", Rect2(0.085, 0.105, 0.055, 0.072), risk_glyphs[clampi(int(option.get("risk", 0)), 0, 2)], [Color("#3d7449"), Color("#a07816"), Color("#a74337")][clampi(int(option.get("risk", 0)), 0, 2)])
|
||
var identity := _risk_commitment_label(overlay, "RiskSelectedIdentity", Rect2(0.16, 0.105, 0.20, 0.048), _risk_title(option), 9, Color("#2d241a"), HORIZONTAL_ALIGNMENT_RIGHT)
|
||
identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
var source := _risk_commitment_label(overlay, "RiskSelectedSource", Rect2(0.16, 0.152, 0.20, 0.034), _tr("来源 %s", "SOURCE %s") % String(option.get("risk_option_id", "")).substr(0, 8), 5, Color("#6e5634"), HORIZONTAL_ALIGNMENT_RIGHT)
|
||
source.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
var contract_id := String(contract.get("contract_id", "CTR-R1-01"))
|
||
_risk_commitment_label(overlay, "RiskSelectedRegion", Rect2(0.10, 0.53, 0.16, 0.04), _tr("区域 %s", "REGION %s") % contract_id, 6, Color("#3d3124"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
var slot_labels := [
|
||
[_tr("威胁", "THREAT"), _tr("%d人 · %d波", "%d · %d WAVES") % [int(option.known_threat.enemy_count), int(option.known_threat.wave_count)], "!", Color("#a74337")],
|
||
[_tr("信息", "INFO"), _risk_uncertainty_short(String(option.uncertainty_id)), "?", Color("#5d3f91")],
|
||
[_tr("入场", "ENTRY"), _tr("%d 金", "%d COIN") % int(option.entry_cost), "¢", Color("#a07816")],
|
||
[_tr("回报", "REWARD"), _tr("%d · %s", "%d · %s") % [int(option.reward_coin), _risk_reward_short(String(option.reward_class_id))], "◆", Color("#3d7449")],
|
||
[_tr("恢复", "RECOVERY"), _recovery_name(String(option.recovery_exposure_id)), "!", Color("#a44a2e")],
|
||
[_tr("撤退", "RETREAT"), _tr("下阶段归队", "NEXT STAGE"), "↩", Color("#2c67a3")],
|
||
]
|
||
for index in range(slot_labels.size()):
|
||
var y := 0.115 + float(index) * 0.073
|
||
_risk_commitment_marker(overlay, "RiskSlotMarker%d" % index, Rect2(0.265, y + 0.004, 0.026, 0.038), String(slot_labels[index][2]), slot_labels[index][3])
|
||
var slot_label := _risk_commitment_label(overlay, "RiskSlotLabel%d" % index, Rect2(0.296, y, 0.095, 0.020), String(slot_labels[index][0]), 5, Color("#766043"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
slot_label.visible = false
|
||
slot_label.set_meta("semantic_node_retained", true)
|
||
var slot_value := _risk_commitment_label(overlay, "RiskSlotValue%d" % index, Rect2(0.292, y + 0.006, 0.101, 0.050), String(slot_labels[index][1]), 7, Color("#31271c"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
slot_value.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
var detail := _risk_commitment_label(overlay, "RiskCommitmentDetail", Rect2(0.08, 0.575, 0.31, 0.045), _risk_detail_copy(option), 6, Color("#5c4930"))
|
||
detail.visible = false
|
||
detail.set_meta("semantic_node_retained", true)
|
||
var recovery_rule := _risk_commitment_label(overlay, "RiskRecoveryRule", Rect2(0.08, 0.615, 0.31, 0.03), _tr("击倒成员仅离开本场;下阶段以同一身份归队。", "A knocked-out member leaves this battle only and returns next stage with the same identity."), 6, Color("#3d7449"))
|
||
recovery_rule.visible = false
|
||
recovery_rule.set_meta("semantic_node_retained", true)
|
||
|
||
# Independent hit areas follow the handoff contract and stay transparent over
|
||
# the painted relic and ledger shell.
|
||
var selected_hit := _risk_commitment_hit_button("RiskSelectedRiskDetails", Rect2(0.055, 0.055, 0.36, 0.585), review_risk_commitment_details, _tr("查看已选风险事实", "Review selected risk facts"))
|
||
selected_hit.set_meta("hit_area_id", "selected_risk_details")
|
||
overlay.add_child(selected_hit)
|
||
var consequence_hit := _risk_commitment_hit_button("RiskConsequenceBand", Rect2(0.075, 0.65, 0.85, 0.145), review_risk_commitment_consequences, _tr("查看六条后果事实", "Review six consequence facts"))
|
||
consequence_hit.set_meta("hit_area_id", "consequence_band")
|
||
overlay.add_child(consequence_hit)
|
||
|
||
# Re-add the semantic labels after the transparent hit areas so the labels
|
||
# remain visually above the interaction layer.
|
||
var fact_root := Control.new()
|
||
fact_root.name = "RiskCommitmentFacts"
|
||
fact_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
fact_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(fact_root)
|
||
var facts := [
|
||
[_tr("威胁", "THREAT"), _tr("%d人 · %d波", "%d · %d WAVES") % [int(option.known_threat.enemy_count), int(option.known_threat.wave_count)], "!", Color("#a74337")],
|
||
[_tr("不确定", "UNCERTAINTY"), _risk_uncertainty_short(String(option.uncertainty_id)), "?", Color("#5d3f91")],
|
||
[_tr("入场 Coin", "ENTRY COIN"), _tr("%d · 余额 %d", "%d · %d LEFT") % [int(option.entry_cost), int(contract.get("coin", 0))], "¢", Color("#a07816")],
|
||
[_tr("胜利回报", "REWARD"), _tr("%d · %s", "%d · %s") % [int(option.reward_coin), _risk_reward_short(String(option.reward_class_id))], "◆", Color("#3d7449")],
|
||
[_tr("恢复暴露", "RECOVERY"), _recovery_name(String(option.recovery_exposure_id)), "!", Color("#a44a2e")],
|
||
[_tr("撤退规则", "RETREAT"), _tr("下阶段归队", "NEXT STAGE"), "↩", Color("#2c67a3")],
|
||
]
|
||
for index in range(facts.size()):
|
||
var fact := Control.new()
|
||
fact.name = "RiskConsequenceFact%d" % index
|
||
_risk_commitment_place(fact, Rect2(0.085 + float(index) * 0.139, 0.668, 0.128, 0.12))
|
||
fact_root.add_child(fact)
|
||
_risk_commitment_marker(fact, "FactMarker", Rect2(0.01, 0.08, 0.16, 0.32), String(facts[index][2]), facts[index][3])
|
||
_risk_commitment_label(fact, "FactLabel", Rect2(0.20, 0.03, 0.78, 0.32), String(facts[index][0]), 7, Color("#766043"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
_risk_commitment_label(fact, "FactValue", Rect2(0.18, 0.37, 0.80, 0.57), String(facts[index][1]), 7, Color("#31271c"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
var consequence_summary := _risk_commitment_label(overlay, "RiskConsequenceSummary", Rect2(0.09, 0.785, 0.82, 0.025), _tr("六条事实均来自当前风险投影;没有隐藏胜率或自动推荐。", "Six facts come from the current risk projection; no hidden win rate or recommendation."), 6, Color("#3e3428"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
consequence_summary.set_meta("semantic_node_retained", true)
|
||
|
||
# Party staging is one non-interactive parent with four complete portraits in
|
||
# the accepted rear-left to front-right presentation order.
|
||
var party_root := Control.new()
|
||
party_root.name = "RiskFrozenMembers"
|
||
_risk_commitment_place(party_root, Rect2(0.47, 0.235, 0.49, 0.40))
|
||
party_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(party_root)
|
||
_risk_commitment_label(overlay, "RiskPartySnapshotTitle", Rect2(0.48, 0.075, 0.25, 0.04), _tr("冻结队伍 · 四人", "FROZEN PARTY · FOUR"), 9, Color("#2d241a"))
|
||
var snapshot_version := String(snapshot.get("snapshot_id", snapshot.get("snapshot_hash", ""))).substr(0, 10)
|
||
_risk_commitment_label(overlay, "RiskPartySnapshotVersion", Rect2(0.80, 0.055, 0.145, 0.04), _tr("快照 %s", "SNAP %s") % snapshot_version, 7, Color("#463725"), HORIZONTAL_ALIGNMENT_RIGHT)
|
||
for visual_index in range(party_ids.size() - 1, -1, -1):
|
||
var position_index := visual_index
|
||
var display_index := party_ids.size() - 1 - visual_index
|
||
var recruit_id := String(party_ids[visual_index])
|
||
var recruit := _recruit_by_id(resolved, recruit_id)
|
||
var member := Control.new()
|
||
member.name = "RiskFrozenMember%d" % display_index
|
||
_risk_commitment_place(member, Rect2(float(display_index) * 0.245, 0.0, 0.225, 1.0))
|
||
member.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
member.set_meta("recruit_id", recruit_id)
|
||
member.set_meta("position_index", position_index)
|
||
party_root.add_child(member)
|
||
var actor := TextureRect.new()
|
||
actor.name = "RecruitPortrait"
|
||
actor.texture = InitialCompanyArt.texture_for(recruit)
|
||
actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
actor.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
actor.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
_risk_commitment_place(actor, Rect2(0.02, 0.02, 0.96, 0.72))
|
||
actor.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
member.add_child(actor)
|
||
var relation := _risk_commitment_relation_for(formation, recruit_id)
|
||
var exposure := _risk_commitment_exposure_text(String(relation.get("exposure_id", "rear")))
|
||
var marker := _risk_commitment_marker(member, "PartyFactMarker", Rect2(0.28, 0.10, 0.16, 0.14), "●", Color("#3d7449"))
|
||
marker.set_meta("reach_id", String(relation.get("reach_id", "limited")))
|
||
_risk_commitment_label(member, "PartyPosition", Rect2(0.02, 0.70, 0.96, 0.12), _tr("位置 %d · %s", "POS %d · %s") % [position_index, exposure], 7, Color("#3a3025"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
_risk_commitment_label(member, "PartyIdentity", Rect2(0.0, 0.81, 1.0, 0.19), _tr("%s\n%s", "%s\n%s") % [_recruit_name(recruit), _recruit_profession_name(recruit)], 7, Color("#2e271e"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
_risk_commitment_label(overlay, "RiskPartyCoverage", Rect2(0.49, 0.505, 0.44, 0.035), _tr("覆盖 · 保护 %d 远程 %d 续航 %d 控制 %d", "COVERAGE · GUARD %d REACH %d SUSTAIN %d CONTROL %d") % [int(formation.coverage.get("protection", 0)), int(formation.coverage.get("long_reach", 0)), int(formation.coverage.get("sustain", 0)), int(formation.coverage.get("control", 0))], 6, Color("#3e3428"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
|
||
# The command rail remains a transparent semantic overlay on the authored
|
||
# dark rail. Begin Battle is disabled until acknowledgement and dependency
|
||
# validation both succeed.
|
||
var cancel := _risk_commitment_command_button("RiskCommitmentCancel", Rect2(0.03, 0.83, 0.19, 0.15), _tr("返回风险板", "RISK BOARD"), "‹", cancel_risk_commitment, false)
|
||
overlay.add_child(cancel)
|
||
var acknowledge := _risk_commitment_command_button("RiskCommitmentAcknowledge", Rect2(0.40, 0.83, 0.20, 0.15), _tr("确认", "ACK") if risk_commitment_acknowledged else _tr("确认", "ACK"), "✓" if risk_commitment_acknowledged else "!", toggle_risk_commitment_confirmation, true)
|
||
acknowledge.set_meta("confirmed", risk_commitment_acknowledged)
|
||
overlay.add_child(acknowledge)
|
||
var begin := _risk_commitment_command_button("RiskBeginBattle", Rect2(0.78, 0.83, 0.19, 0.15), _tr("开始战斗", "BEGIN BATTLE"), "➜", begin_battle, true, not ready)
|
||
begin.tooltip_text = _tr("确认后创建一次承诺回执并进入战斗开场。", "After acknowledgement, create one commitment receipt and enter Battle Opening.") if not begin.disabled else _risk_commitment_block_reason(dependency, risk_commitment_acknowledged)
|
||
_set_risk_commitment_button_disabled(begin, not ready)
|
||
overlay.add_child(begin)
|
||
# Historical DEV-4 callers inspect this node to verify the pre-commit boundary.
|
||
# Keep it as a non-rendered disabled alias while the real command owns the slot.
|
||
var unavailable := Button.new()
|
||
unavailable.name = "RiskBeginBattleUnavailable"
|
||
unavailable.visible = false
|
||
unavailable.disabled = true
|
||
unavailable.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
unavailable.set_meta("semantic_alias", "RiskBeginBattle")
|
||
overlay.add_child(unavailable)
|
||
var summary := _risk_commitment_label(overlay, "RiskCommitmentSummary", Rect2(0.36, 0.855, 0.28, 0.028), _tr("一次回执", "ONE RECEIPT"), 5, Color("#ead8ad"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
summary.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
var reason_text := risk_commitment_notice
|
||
if reason_text.is_empty():
|
||
reason_text = _tr("先确认后果;确认前不改变金币或队伍。", "Acknowledge first; Coin and Party stay unchanged.") if not risk_commitment_acknowledged else _tr("已确认:下一步进入战斗开场。", "Acknowledged: next step opens Battle Opening.")
|
||
if not bool(dependency.get("ok", false)):
|
||
reason_text = _risk_commitment_block_reason(dependency, risk_commitment_acknowledged)
|
||
var reason := _risk_commitment_label(overlay, "RiskBeginBattleReason", Rect2(0.34, 0.925, 0.32, 0.038), reason_text, 6, Color("#f0ca75") if not ready else Color("#9ed7aa"), HORIZONTAL_ALIGNMENT_CENTER)
|
||
reason.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
|
||
var focus_nodes: Array[Control] = [settings_button, selected_hit, consequence_hit, acknowledge, begin, cancel]
|
||
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()])
|
||
settings_button.call_deferred("grab_focus")
|
||
|
||
|
||
func _risk_commitment_relation_for(formation: Dictionary, recruit_id: String) -> Dictionary:
|
||
for relation in formation.get("relations", []):
|
||
if String(relation.get("recruit_id", "")) == recruit_id:
|
||
return Dictionary(relation)
|
||
return {}
|
||
|
||
|
||
func _risk_commitment_exposure_text(value: String) -> String:
|
||
match value:
|
||
"highest": return _tr("最前", "FRONT")
|
||
"high": return _tr("前列", "HIGH")
|
||
"guarded": return _tr("受护", "GUARDED")
|
||
return _tr("后列", "REAR")
|
||
|
||
|
||
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 _set_risk_commitment_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
|
||
var icon := button.get_node_or_null("CommandGlyph") as Label
|
||
if label != null:
|
||
label.add_theme_color_override("font_color", Color("#89939a") if disabled else Color("#fff4d2"))
|
||
if icon != null:
|
||
icon.add_theme_color_override("font_color", Color("#89939a") if disabled else Color("#f3d991"))
|
||
|
||
|
||
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.")
|
||
var reason := find_child("RiskBeginBattleReason", true, false) as Label
|
||
if reason != null:
|
||
reason.text = risk_commitment_notice
|
||
|
||
|
||
func review_risk_commitment_consequences() -> void:
|
||
risk_commitment_notice = _tr("后果顺序:威胁、不确定、入场、回报、恢复、撤退。", "Order: threat, uncertainty, entry, reward, recovery, retreat.")
|
||
var reason := find_child("RiskBeginBattleReason", true, false) as Label
|
||
if reason != null:
|
||
reason.text = risk_commitment_notice
|
||
|
||
|
||
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
|
||
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)
|
||
_build_battle_opening_field(overlay, company, battle)
|
||
_build_battle_opening_bottom(overlay, company, battle)
|
||
|
||
|
||
func _build_battle_opening_top(parent: Control, company: Dictionary, battle: Dictionary) -> void:
|
||
var party_count := Array(battle.get("front_to_rear_ids", [])).size()
|
||
var enemy_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) -> void:
|
||
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 commitment := _battle_opening_overlay_label(parent, "BattleOpeningCommitment", _tr("承诺回执 %s", "COMMITMENT %s") % _short_hash(risk_commitment_receipt.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(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(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(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", 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) -> void:
|
||
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(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(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 := 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 = battle_opening_state == "opening_handoff"
|
||
var receipt := _battle_opening_overlay_label(parent, "BattleOpeningReceipt", _tr("战斗回执 %s", "BATTLE RECEIPT %s") % _short_hash(_battle_receipt_hash(company, battle)), 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 = 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
|
||
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
|
||
_build_battle_live_canvas()
|
||
var battle: Dictionary = guild_company.get("active_contract", {}).get("battle_state", {})
|
||
_build_battle_live_overlay(guild_company, battle)
|
||
_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 _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) -> 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 source_id := String(battle_live_event.get("actor_id", ""))
|
||
var target_id := String(battle_live_event.get("target_id", ""))
|
||
art.set_event_state(
|
||
battle_live_state,
|
||
String(battle_live_event.get("kind", "")),
|
||
_battle_live_actor_side(source_id, battle),
|
||
_battle_live_actor_side(target_id, battle)
|
||
)
|
||
art.set_presentation_state(
|
||
battle_paused,
|
||
battle_speed,
|
||
_battle_live_marker_anchors(battle, true),
|
||
_battle_live_marker_anchors(battle, false),
|
||
_battle_live_actor_anchor(source_id, battle, true),
|
||
_battle_live_actor_anchor(target_id, 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)
|
||
_build_battle_live_field(overlay, company, battle)
|
||
_build_battle_live_bottom(overlay, company, battle)
|
||
|
||
|
||
func _build_battle_live_top(parent: Control, battle: Dictionary) -> void:
|
||
var party_count := Array(battle.get("front_to_rear_ids", [])).size()
|
||
var enemy_count := Array(battle.get("enemy_ids", [])).size()
|
||
var company_status := _battle_live_overlay_label(parent, "BattleLiveCompanyStatus", _tr("%d 远征团", "%d COMPANY") % party_count, Rect2(0.090, 0.025, 0.155, 0.055), 11, Color("#f5ead0"))
|
||
company_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
var enemy_status := _battle_live_overlay_label(parent, "BattleLiveEnemyStatus", _tr("敌方 %d", "ENEMY %d") % enemy_count, Rect2(0.790, 0.025, 0.120, 0.055), 11, Color("#f5ead0"))
|
||
enemy_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
var controls_locked := battle_live_state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"]
|
||
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, controls_locked or battle_live_state in ["inspect_handoff", "pause_controls_handoff"])
|
||
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, controls_locked or battle_live_state in ["action_committed", "event_resolving", "inspect_handoff", "pause_controls_handoff"])
|
||
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, battle_live_state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle", "pause_controls_handoff"])
|
||
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, battle_live_state in ["invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"])
|
||
|
||
|
||
func _build_battle_live_field(parent: Control, company: Dictionary, battle: Dictionary) -> void:
|
||
var objective_title := _battle_live_overlay_label(parent, "BattleLiveTitle", _tr("风镜初渡 · 自动观察", "WINDGLASS CROSSING · LIVE"), Rect2(0.030, 0.148, 0.245, 0.024), 8, Color("#e8d39d"))
|
||
objective_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
var objective := _battle_live_overlay_label(parent, "BattleLiveObjective", _tr("击破敌方队列", "BREAK THE ENEMY LINE"), Rect2(0.030, 0.173, 0.245, 0.032), 11, Color("#f5ead0"))
|
||
objective.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
var no_respawn := _battle_live_overlay_label(parent, "BattleLiveNoRespawn", _tr("不可复活", "NO RESPAWN"), Rect2(0.030, 0.207, 0.105, 0.023), 7, Color("#f0c979"))
|
||
no_respawn.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
var no_reserve := _battle_live_overlay_label(parent, "BattleLiveNoReserve", _tr("无后备入场", "NO RESERVE"), Rect2(0.138, 0.207, 0.137, 0.023), 7, Color("#f0c979"))
|
||
no_reserve.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
|
||
var has_event := not battle_live_event.is_empty() and battle_live_state != "waiting_for_next_event"
|
||
var opening_event := String(battle_live_event.get("kind", "")) == "opening"
|
||
var event_state := _battle_live_overlay_label(parent, "BattleLiveEventState", _battle_live_state_copy(), Rect2(0.555, 0.350, 0.15, 0.022), 6, Color("#e8d39d"))
|
||
event_state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
event_state.visible = not opening_event and (has_event or battle_live_state in ["paused", "inspect_handoff", "pause_controls_handoff", "outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"])
|
||
var event_title_text := _battle_live_event_title()
|
||
var event_title_rect := Rect2(0.370, 0.295, 0.26, 0.042) if not has_event else Rect2(0.555, 0.375, 0.17, 0.032)
|
||
var event_title_size := 12 if not has_event else 8
|
||
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 battle_live_event.is_empty()
|
||
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), 7, 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(battle_live_event.get("kind", "")) != "opening"
|
||
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 (has_event or battle_live_state in ["outcome_locked", "invalid_event_data", "interrupted_background"])
|
||
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()
|
||
_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 = 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 = 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(battle)
|
||
var recent := _battle_live_overlay_label(parent, "BattleLiveRecentEvents", recent_copy, Rect2(0.30, 0.758, 0.40, 0.028), 7, Color("#edf0dd"))
|
||
recent.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
recent.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
recent.visible = not recent_copy.is_empty()
|
||
|
||
|
||
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 _build_battle_live_bottom(parent: Control, company: Dictionary, battle: Dictionary) -> void:
|
||
var tick := int(battle_live_observation.get("next_phase_index", 0))
|
||
var total := int(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(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 = 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, Rect2(0.170 + visual_index * 0.153, 0.817, 0.143, 0.165)))
|
||
var next_text := _tr("撤退", "RETREAT")
|
||
var next_action := Callable(self, "_battle_live_open_retreat_entry")
|
||
var next_disabled := battle_live_state in ["action_committed", "event_resolving", "invalid_event_data", "snapshot_retrying", "loading_resume", "no_resumable_battle"]
|
||
var next_tooltip := _tr("BAT-004/BAT-005 负责控制与确认", "BAT-004/BAT-005 own controls and confirmation")
|
||
match battle_live_state:
|
||
"outcome_locked":
|
||
next_text = "OUT-001"
|
||
next_action = Callable(self, "_battle_live_outcome_handoff")
|
||
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.825, 0.810, 0.155, 0.180), next_action, false)
|
||
next_button.tooltip_text = next_tooltip
|
||
_set_battle_live_button_disabled(next_button, next_disabled)
|
||
|
||
|
||
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
|
||
var portrait := TextureRect.new()
|
||
portrait.name = "BattleLiveStatePortrait"
|
||
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.03
|
||
portrait.anchor_top = 0.015
|
||
portrait.anchor_right = 0.55
|
||
portrait.anchor_bottom = 0.71
|
||
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.0
|
||
position_label.anchor_top = 0.0
|
||
position_label.anchor_right = 0.18
|
||
position_label.anchor_bottom = 0.22
|
||
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(7), Color("#f5ead0"))
|
||
identity.name = "BattleLiveStateIdentity"
|
||
identity.anchor_left = 0.48
|
||
identity.anchor_top = 0.12
|
||
identity.anchor_right = 0.96
|
||
identity.anchor_bottom = 0.40
|
||
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
identity.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
cell.add_child(identity)
|
||
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(6), Color("#e98b83") if fallen else Color("#8fd2a2"))
|
||
state.name = "BattleLiveStateStatus"
|
||
state.anchor_left = 0.48
|
||
state.anchor_top = 0.42
|
||
state.anchor_right = 0.96
|
||
state.anchor_bottom = 0.64
|
||
state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
state.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
cell.add_child(state)
|
||
var hp := ColorRect.new()
|
||
hp.name = "BattleLiveStateHP"
|
||
hp.anchor_left = 0.070
|
||
hp.anchor_top = 0.752
|
||
hp.anchor_right = 0.070 if fallen else 0.930
|
||
hp.anchor_bottom = 0.794
|
||
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.872
|
||
resource.anchor_right = 0.070 if fallen else 0.820
|
||
resource.anchor_bottom = 0.914
|
||
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_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)
|
||
actor.add_child(contour)
|
||
|
||
|
||
func _battle_live_ally_slots() -> Array[Rect2]:
|
||
return [
|
||
Rect2(0.060, 0.440, 0.115, 0.245),
|
||
Rect2(0.175, 0.430, 0.115, 0.255),
|
||
Rect2(0.290, 0.420, 0.115, 0.265),
|
||
Rect2(0.405, 0.410, 0.115, 0.275),
|
||
]
|
||
|
||
|
||
func _battle_live_enemy_slots() -> Array[Rect2]:
|
||
return [
|
||
Rect2(0.625, 0.455, 0.105, 0.225),
|
||
Rect2(0.745, 0.435, 0.105, 0.245),
|
||
Rect2(0.865, 0.415, 0.105, 0.265),
|
||
]
|
||
|
||
|
||
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_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":
|
||
var outcome_lock: Dictionary = battle_live_observation.get("outcome_lock", {})
|
||
return _tr("原因 %s · 日志 %s", "CAUSE %s · JOURNAL %s") % [String(outcome_lock.get("outcome_cause", "")), _short_hash(outcome_lock.get("event_journal_hash", ""))]
|
||
"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_name(company, battle, source_id)
|
||
var target := _battle_live_actor_name(company, battle, target_id)
|
||
var amount := int(battle_live_event.get("amount", 0))
|
||
if source_id.is_empty():
|
||
return _tr("目标 %s · 数值 %d", "TARGET %s · VALUE %d") % [target, amount]
|
||
if target_id.is_empty():
|
||
return _tr("来源 %s · 数值 %d", "SOURCE %s · VALUE %d") % [source, amount]
|
||
return _tr("%s → %s · 数值 %d", "%s -> %s · VALUE %d") % [source, target, amount]
|
||
|
||
|
||
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 "OUTCOME %s" % _short_hash(Dictionary(battle_live_observation.get("outcome_lock", {})).get("outcome_id", ""))
|
||
if battle_live_event.is_empty() or battle_live_state == "waiting_for_next_event":
|
||
return _tr("回执已验证", "RECEIPT VERIFIED")
|
||
return "EVENT %s · TICK %d" % [_short_hash(battle_live_event.get("event_id", "")), 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_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"
|
||
_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("只交接事件、角色和快照身份;BAT-003 页面尚未实现", "Only event, actor and snapshot identity are handed off; BAT-003 is not implemented here")
|
||
_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")
|
||
_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
|
||
_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_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 frame_safe := _framed_page_safe("BattleResult")
|
||
frame_safe.add_child(_entry_chrome("BattleResultFrame", "initial_company_scene_frame"))
|
||
var safe := _content_page_safe("BattleResult")
|
||
var battle: Dictionary = resolved.active_contract.get("battle_state", {})
|
||
var result: Dictionary = battle.get("result", {})
|
||
var short_result := size.y < 500.0
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "BattleResultLayout"
|
||
layout.add_theme_constant_override("separation", 5)
|
||
safe.add_child(layout)
|
||
var header := HBoxContainer.new()
|
||
header.name = "BattleResultHeader"
|
||
header.custom_minimum_size = Vector2(0, 44 if short_result else 54)
|
||
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("战斗结果", "BATTLE RESULT"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_tr("风镜初渡 · 可审计结算", "FIRST WINDGLASS CROSSING · REVIEWABLE SETTLEMENT"), _scaled(10), Color("#e8d39d")))
|
||
var outcome := _label(_battle_outcome_text(String(result.get("outcome", "unknown"))), _scaled(15), Color("#9ed7aa") if String(result.get("outcome", "")) == "victory" else Color("#ffd4d4"))
|
||
outcome.name = "BattleResultSummary"
|
||
outcome.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(outcome)
|
||
var body := HBoxContainer.new()
|
||
body.name = "BattleResultBody"
|
||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_theme_constant_override("separation", 14)
|
||
layout.add_child(body)
|
||
var report := VBoxContainer.new()
|
||
report.name = "BattleResultReport"
|
||
report.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
report.size_flags_stretch_ratio = 1.35
|
||
report.add_theme_constant_override("separation", 5)
|
||
body.add_child(report)
|
||
var objective := _label(_tr("目标:击破敌方队列", "OBJECTIVE: BREAK THE ENEMY LINE"), _scaled(13), Color("#f5ead0"))
|
||
objective.name = "BattleResultObjective"
|
||
report.add_child(objective)
|
||
var decisive := _battle_result_decisive_event(battle)
|
||
var decisive_copy := _label(
|
||
_tr("第一决定性事件:%s", "FIRST DECISIVE EVENT: %s") % _battle_event_text(String(decisive.get("caption_id", ""))),
|
||
_scaled(13), Color("#f0ca75")
|
||
)
|
||
decisive_copy.name = "BattleResultDecisive"
|
||
decisive_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
report.add_child(decisive_copy)
|
||
var totals := HBoxContainer.new()
|
||
totals.name = "BattleResultTotals"
|
||
totals.custom_minimum_size = Vector2(0, 42 if short_result else 48)
|
||
totals.add_theme_constant_override("separation", 8)
|
||
report.add_child(totals)
|
||
var coin := _label(_tr("金币 %d → %d\n+%d", "COIN %d -> %d\n+%d") % [int(result.get("coin_before", 0)), int(result.get("coin_after", 0)), int(result.get("reward_coin", 0))], _scaled(12), Color("#f0ca75"))
|
||
coin.name = "BattleResultReward"
|
||
coin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
coin.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
totals.add_child(coin)
|
||
var xp := _label(_tr("经验 +%d\n存活成员各 +%d", "XP +%d\n%d per survivor") % [int(result.get("xp_award", 0)), int(result.get("xp_per_recruit", 0))], _scaled(12), Color("#9ed7aa"))
|
||
xp.name = "BattleResultXP"
|
||
xp.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
xp.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
totals.add_child(xp)
|
||
var knockouts: Array = result.get("knocked_out_ids", [])
|
||
var knockout_copy := _label(_battle_result_knockout_text(resolved, knockouts), _scaled(11), Color("#ffd4d4") if not knockouts.is_empty() else Color("#9ed7aa"))
|
||
knockout_copy.name = "BattleResultKnockouts"
|
||
knockout_copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
knockout_copy.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
report.add_child(knockout_copy)
|
||
var context := VBoxContainer.new()
|
||
context.name = "BattleResultDiagnosisContext"
|
||
context.custom_minimum_size = Vector2(minf(350.0, size.x * 0.38), 0)
|
||
context.add_theme_constant_override("separation", 4)
|
||
body.add_child(context)
|
||
context.add_child(_label(_tr("因果链", "CAUSAL CHAIN"), _scaled(10), Color("#e8d39d")))
|
||
var chain := _label(_battle_result_chain_summary(battle), _scaled(11), Color("#f5ead0"))
|
||
chain.name = "BattleResultChain"
|
||
chain.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
chain.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
context.add_child(chain)
|
||
if battle_result_diagnosis_open:
|
||
var detail := _label(_battle_result_diagnosis_text(resolved, battle), _scaled(10), Color("#aebbc2"))
|
||
detail.name = "BattleResultDiagnosisDetail"
|
||
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
detail.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
context.add_child(detail)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "BattleResultFooter"
|
||
footer.custom_minimum_size = Vector2(0, 46 if short_result else 52)
|
||
footer.add_theme_constant_override("separation", 8)
|
||
layout.add_child(footer)
|
||
var inspect := _company_button("BattleResultDiagnosis", _tr("因果详情", "Cause Details"), toggle_battle_result_diagnosis, "company_secondary", "inspect")
|
||
inspect.custom_minimum_size = Vector2(230, footer.custom_minimum_size.y)
|
||
footer.add_child(inspect)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var continue_button := _company_button("BattleResultContinue", _tr("继续", "Continue"), continue_from_battle_result, "company_primary", "confirm")
|
||
continue_button.custom_minimum_size = Vector2(220, footer.custom_minimum_size.y)
|
||
continue_button.tooltip_text = _tr("先查看因果链,再进入下一阶段恢复", "Review the causal chain before next-stage recovery")
|
||
footer.add_child(continue_button)
|
||
inspect.call_deferred("grab_focus")
|
||
|
||
|
||
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:
|
||
show_battle_knockout_review(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 frame_safe := _framed_page_safe("BattleKnockout")
|
||
frame_safe.add_child(_entry_chrome("BattleKnockoutFrame", "initial_company_scene_frame"))
|
||
var safe := _content_page_safe("BattleKnockout")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "BattleKnockoutLayout"
|
||
layout.add_theme_constant_override("separation", 5)
|
||
safe.add_child(layout)
|
||
var header := HBoxContainer.new()
|
||
header.custom_minimum_size = Vector2(0, 46 if size.y < 500.0 else 54)
|
||
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("击倒回顾", "KNOCKOUT REVIEW"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_tr("确认战斗内后果;没有成员被删除。", "Confirm the battle-local consequence; no Recruit was deleted."), _scaled(10), Color("#e8d39d")))
|
||
var status := _label(_tr("战斗回执已提交", "BATTLE RECEIPT COMMITTED"), _scaled(11), Color("#9ed7aa"))
|
||
status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(status)
|
||
var body := HBoxContainer.new()
|
||
body.name = "BattleKnockoutBody"
|
||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_theme_constant_override("separation", 14)
|
||
layout.add_child(body)
|
||
var scene := VBoxContainer.new()
|
||
scene.name = "BattleKnockoutScene"
|
||
scene.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
scene.size_flags_stretch_ratio = 1.2
|
||
scene.add_theme_constant_override("separation", 3)
|
||
body.add_child(scene)
|
||
var battle: Dictionary = resolved.active_contract.get("battle_state", {})
|
||
var knocked: Array = battle.get("result", {}).get("knocked_out_ids", [])
|
||
if knocked.is_empty():
|
||
scene.add_child(_label(_tr("本场没有击倒成员。", "No Recruit was knocked out in this battle."), _scaled(15), Color("#9ed7aa")))
|
||
else:
|
||
for recruit_id in knocked:
|
||
var recruit := _recruit_by_id(resolved, String(recruit_id))
|
||
if recruit.is_empty():
|
||
continue
|
||
scene.add_child(_readiness_member(0, recruit))
|
||
scene.add_child(_label(_tr("%s · %s", "%s · %s") % [_recruit_name(recruit), _recruit_profession_name(recruit)], _scaled(16), Color("#f0ca75")))
|
||
scene.add_child(_label(_tr("特质:%s\n等级 %d · XP %d\n装备:%s", "Trait: %s\nLevel %d · XP %d\nEquipment: %s") % [_recruit_trait_name(recruit), int(recruit.level), int(recruit.xp), _recruit_item_name(recruit)], _scaled(11), Color("#f5ead0")))
|
||
var context := VBoxContainer.new()
|
||
context.name = "BattleKnockoutContext"
|
||
context.custom_minimum_size = Vector2(minf(350.0, size.x * 0.38), 0)
|
||
context.add_theme_constant_override("separation", 5)
|
||
body.add_child(context)
|
||
context.add_child(_label(_tr("当前战斗空位", "CURRENT BATTLE GAP"), _scaled(10), Color("#e8d39d")))
|
||
var gap := _label(_tr("被击倒成员只离开本场战斗。其稳定 Recruit ID、Profession、Trait、等级、XP 和装备所有权仍在 Company 中。", "The knocked-out member leaves this battle only. The stable Recruit ID, Profession, Trait, level, XP and equipment ownership remain in the Company."), _scaled(12), Color("#ffd4d4"))
|
||
gap.name = "BattleKnockoutCurrentGap"
|
||
gap.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
gap.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
context.add_child(gap)
|
||
var return_rule := _label(_tr("下关归队:恢复后进入下一阶段 Market,不会永久死亡。", "Next-stage return: recovery opens the next Market; there is no permanent death."), _scaled(11), Color("#9ed7aa"))
|
||
return_rule.name = "BattleKnockoutReturnRule"
|
||
return_rule.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
context.add_child(return_rule)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "BattleKnockoutFooter"
|
||
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 back := _company_button("BattleKnockoutBack", _tr("返回结果", "Back to Result"), show_battle_result.bind(resolved), "company_utility", "back")
|
||
footer.add_child(back)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var acknowledge := _company_button("BattleKnockoutAcknowledge", _tr("确认击倒", "Acknowledge"), acknowledge_battle_knockout.bind(resolved), "company_primary", "confirm")
|
||
acknowledge.custom_minimum_size = Vector2(230, footer.custom_minimum_size.y)
|
||
acknowledge.tooltip_text = _tr("确认已提交的战斗内后果;不会删除成员", "Acknowledge the committed battle-local consequence; the member is not deleted")
|
||
footer.add_child(acknowledge)
|
||
back.call_deferred("grab_focus")
|
||
|
||
|
||
func acknowledge_battle_knockout(company := {}) -> void:
|
||
show_battle_recovery(company)
|
||
|
||
|
||
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 frame_safe := _framed_page_safe("BattleRecovery")
|
||
frame_safe.add_child(_entry_chrome("BattleRecoveryFrame", "initial_company_scene_frame"))
|
||
var safe := _content_page_safe("BattleRecovery")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "BattleRecoveryLayout"
|
||
layout.add_theme_constant_override("separation", 6)
|
||
safe.add_child(layout)
|
||
var heading := VBoxContainer.new()
|
||
heading.custom_minimum_size = Vector2(0, 48 if size.y < 500.0 else 58)
|
||
layout.add_child(heading)
|
||
heading.add_child(_label(_tr("下一阶段恢复", "NEXT-STAGE RECOVERY"), _scaled(21), Color("#f5ead0")))
|
||
heading.add_child(_label(_tr("一次原子回执将恢复同一 Company 的下一阶段准备入口。", "One atomic receipt restores the next-stage preparation entry for the same Company."), _scaled(10), Color("#e8d39d")))
|
||
var body := HBoxContainer.new()
|
||
body.name = "BattleRecoveryBody"
|
||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_theme_constant_override("separation", 14)
|
||
layout.add_child(body)
|
||
var summary := VBoxContainer.new()
|
||
summary.name = "BattleRecoverySummary"
|
||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
summary.add_theme_constant_override("separation", 5)
|
||
body.add_child(summary)
|
||
var battle: Dictionary = resolved.active_contract.get("battle_state", {})
|
||
var knocked: Array = battle.get("result", {}).get("knocked_out_ids", [])
|
||
summary.add_child(_label(_tr("恢复成员 %d 名", "%d Recruit(s) to return") % knocked.size(), _scaled(16), Color("#f0ca75")))
|
||
var ids := _label("\n".join(_recovery_identity_lines(resolved, knocked)), _scaled(11), Color("#f5ead0"))
|
||
ids.name = "BattleRecoveryIdentities"
|
||
ids.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
ids.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
summary.add_child(ids)
|
||
var context := VBoxContainer.new()
|
||
context.name = "BattleRecoveryContext"
|
||
context.custom_minimum_size = Vector2(minf(350.0, size.x * 0.38), 0)
|
||
context.add_theme_constant_override("separation", 5)
|
||
body.add_child(context)
|
||
context.add_child(_label(_tr("恢复承诺", "RECOVERY COMMITMENT"), _scaled(10), Color("#e8d39d")))
|
||
var rule := _label(_tr("保留稳定 ID、Profession、Trait、等级、XP、历史和装备。战斗状态回到 Market;下一阶段可以重新建立 Party。", "Stable ID, Profession, Trait, level, XP, history and equipment are retained. Battle state returns to Market; the next stage can establish a new Party."), _scaled(12), Color("#9ed7aa"))
|
||
rule.name = "BattleRecoveryRule"
|
||
rule.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
rule.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
context.add_child(rule)
|
||
var receipt := _label(_tr("恢复回执只提交一次;重试会返回原回执。", "The recovery receipt submits once; retries return the original receipt."), _scaled(10), Color("#e8d39d"))
|
||
receipt.name = "BattleRecoveryReceiptRule"
|
||
receipt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
context.add_child(receipt)
|
||
var notice := _label(battle_notice, _scaled(10), Color("#ffd4d4"))
|
||
notice.name = "BattleRecoveryNotice"
|
||
notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
context.add_child(notice)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "BattleRecoveryFooter"
|
||
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 back := _company_button("BattleRecoveryBack", _tr("返回", "Back"), show_battle_knockout_review.bind(resolved), "company_utility", "back")
|
||
footer.add_child(back)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(spacer)
|
||
var commit := _company_button("BattleRecoveryCommit", _tr("恢复并进入市场", "Return to Market"), commit_battle_recovery, "company_primary", "confirm")
|
||
commit.custom_minimum_size = Vector2(260, footer.custom_minimum_size.y)
|
||
commit.tooltip_text = _tr("恢复相同 Recruit 身份并打开下一阶段 Market", "Return the same Recruit identities and open the next-stage Market")
|
||
footer.add_child(commit)
|
||
back.call_deferred("grab_focus")
|
||
|
||
|
||
func commit_battle_recovery() -> void:
|
||
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 _market_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 offer := _market_offer_by_id(guild_company.active_contract, kind, offer_id)
|
||
if offer.is_empty():
|
||
market_notice = _tr("该货单条目已不可用。", "That ledger entry is no longer available.")
|
||
else:
|
||
market_selected_kind = kind
|
||
market_selected_offer_id = offer_id
|
||
market_notice = ""
|
||
show_market(guild_company)
|
||
|
||
|
||
func open_recruit_review() -> void:
|
||
if market_selected_kind != "recruit" or market_selected_offer_id.is_empty():
|
||
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:
|
||
if market_selected_kind != "equipment" or market_selected_offer_id.is_empty():
|
||
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 offer := _market_offer_by_id(loaded.company.active_contract, "recruit", offer_id)
|
||
if offer.is_empty():
|
||
market_notice = _tr("该招募候选已不可用。", "That Recruit offer is no longer available.")
|
||
show_market(loaded.company)
|
||
return
|
||
guild_company = loaded.company.duplicate(true)
|
||
market_category = "recruit"
|
||
market_selected_kind = "recruit"
|
||
market_selected_offer_id = offer_id
|
||
current_screen = "recruit_review"
|
||
_build_world_base(true)
|
||
var frame_safe := _framed_page_safe("RecruitReview")
|
||
frame_safe.add_child(_entry_chrome("RecruitReviewFrame", "content_scene_frame"))
|
||
var safe := _content_page_safe("RecruitReview")
|
||
var layout := VBoxContainer.new()
|
||
layout.name = "RecruitReviewLayout"
|
||
layout.add_theme_constant_override("separation", 6)
|
||
safe.add_child(layout)
|
||
var contract: Dictionary = guild_company.active_contract
|
||
var header := HBoxContainer.new()
|
||
header.custom_minimum_size = Vector2(0, 44)
|
||
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("招募详情", "RECRUIT REVIEW"), _scaled(20), Color("#f5ead0")))
|
||
heading.add_child(_label(_market_recruit_name(offer), _scaled(11), Color("#e8d39d")))
|
||
var price := _label(_tr("%d 金币", "%d COIN") % int(offer.list_price), _scaled(15), Color("#f0ca75"))
|
||
price.name = "RecruitReviewPrice"
|
||
price.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.add_child(price)
|
||
var body := HBoxContainer.new()
|
||
body.name = "RecruitReviewBody"
|
||
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 = "RecruitReviewPortrait"
|
||
portrait_stage.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
portrait_stage.size_flags_stretch_ratio = 0.9
|
||
body.add_child(portrait_stage)
|
||
var portrait := TextureRect.new()
|
||
portrait.name = "RecruitReviewPortraitTexture"
|
||
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.anchor_left = 0.08
|
||
portrait.anchor_right = 0.92
|
||
portrait.anchor_bottom = 1.0
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
portrait_stage.add_child(portrait)
|
||
var scroll := ScrollContainer.new()
|
||
scroll.name = "RecruitReviewScroll"
|
||
scroll.custom_minimum_size = Vector2(minf(430.0, size.x * 0.52), 0)
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body.add_child(scroll)
|
||
var details := VBoxContainer.new()
|
||
details.name = "RecruitReviewDetails"
|
||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
details.add_theme_constant_override("separation", 5)
|
||
scroll.add_child(details)
|
||
_add_review_detail(details, "RecruitReviewProfession", _tr("职业", "PROFESSION"), "%s · %s" % [_content_name("professions", String(offer.profession_id)), _profession_job(offer)])
|
||
_add_review_detail(details, "RecruitReviewTrait", _tr("特质", "TRAIT"), "%s\n%s: %s\n%s: %s" % [_content_name("traits", String(offer.trait_id)), _tr("收益", "Benefit"), _trait_value(offer, "benefit"), _tr("代价", "Cost"), _trait_value(offer, "cost")])
|
||
_add_review_detail(details, "RecruitReviewAttributes", _tr("属性", "ATTRIBUTES"), _attribute_summary(offer))
|
||
_add_review_detail(details, "RecruitReviewItem", _tr("初始装备", "STARTING ITEM"), "%s · %s" % [_content_name("equipment", String(offer.starting_equipment_id)), _equipment_effect(offer)])
|
||
_add_review_detail(details, "RecruitReviewHistory", _tr("成长记录", "GROWTH & HISTORY"), _tr("等级 1 · 经验 0 · 参战 0 · 击倒 0", "Level 1 · XP 0 · Battles 0 · Knockouts 0"))
|
||
var offer_available: bool = offer_id in contract.available_recruit_offer_ids
|
||
var opening_hires_complete: bool = contract.hire_receipts.size() >= 2
|
||
var impact_text := _tr("远征团 %d → %d · 队列容量保持 %d;编队在下一步决定。", "Company %d → %d · Line capacity remains %d; formation is decided next.") % [guild_company.owned_recruits.size(), guild_company.owned_recruits.size() + 1, int(contract.line_capacity)]
|
||
if not offer_available:
|
||
impact_text = _tr("该成员已加入远征团;队列容量仍为 %d。", "This Recruit already joined the Company; Line capacity remains %d.") % int(contract.line_capacity)
|
||
elif opening_hires_complete:
|
||
impact_text = _tr("开场两笔招募已完成;先建立编队。队列容量仍为 %d。", "The two opening hires are complete; establish formation next. Line capacity remains %d.") % int(contract.line_capacity)
|
||
_add_review_detail(details, "RecruitReviewImpact", _tr("远征团影响", "COMPANY IMPACT"), impact_text)
|
||
var footer := HBoxContainer.new()
|
||
footer.name = "RecruitReviewFooter"
|
||
footer.custom_minimum_size = Vector2(0, 48)
|
||
layout.add_child(footer)
|
||
var cancel := _company_button("RecruitReviewCancel", _tr("取消并返回同一货单", "Cancel to Same Offer"), cancel_recruit_review, "company_utility", "back")
|
||
cancel.custom_minimum_size = Vector2(250, 48)
|
||
footer.add_child(cancel)
|
||
var footer_spacer := Control.new()
|
||
footer_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
footer.add_child(footer_spacer)
|
||
if offer_available and not opening_hires_complete and int(contract.coin) >= int(offer.list_price):
|
||
var hire := _company_button("RecruitHire", _tr("招募 · %d 金币", "Recruit · %d Coin") % int(offer.list_price), hire_selected_recruit, "company_primary", "confirm")
|
||
hire.custom_minimum_size = Vector2(230, 48)
|
||
hire.tooltip_text = _tr("确认招募 %s,花费 %d 金币,远征团 %d → %d", "Recruit %s for %d Coin; Company %d → %d") % [_market_recruit_name(offer), int(offer.list_price), guild_company.owned_recruits.size(), guild_company.owned_recruits.size() + 1]
|
||
footer.add_child(hire)
|
||
else:
|
||
var state_text := _tr("已招募 · 无重复扣款", "HIRED · NO DUPLICATE CHARGE") if not offer_available else _tr("开场招募已完成 · 下一步建立编队", "OPENING HIRES COMPLETE · FORMATION NEXT") if opening_hires_complete else _tr("金币不足 · 未改变状态", "INSUFFICIENT COIN · NO CHANGE")
|
||
var state := _label(state_text, _scaled(10), Color("#9ed7aa"))
|
||
state.name = "RecruitHireState"
|
||
state.custom_minimum_size = Vector2(190, 0)
|
||
state.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
footer.add_child(state)
|
||
cancel.call_deferred("grab_focus")
|
||
|
||
|
||
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 contract: Dictionary = guild_company.active_contract
|
||
var offer := _market_offer_by_id(contract, "recruit", market_selected_offer_id)
|
||
if offer.is_empty():
|
||
market_notice = _tr("该招募候选已不可用。", "That Recruit offer is unavailable.")
|
||
show_market(guild_company)
|
||
return
|
||
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:
|
||
for offer in contract.market_board.get(_market_collection_field(kind), []):
|
||
if _market_offer_id(kind, offer) == offer_id:
|
||
return Dictionary(offer).duplicate(true)
|
||
return {}
|
||
|
||
|
||
func _market_offer_title(kind: String, offer: Dictionary) -> String:
|
||
match kind:
|
||
"recruit": return _market_recruit_name(offer)
|
||
"equipment": return _content_name("equipment", String(offer.catalog_id))
|
||
"battle": return _tr("路线 %d · 风压 %d", "Route %d · Risk %d") % [int(offer.get("risk", 0)) + 1, int(offer.get("risk", 0))]
|
||
return ""
|
||
|
||
|
||
func _market_offer_detail(kind: String, offer: Dictionary) -> String:
|
||
match kind:
|
||
"recruit":
|
||
var detail := "%s · %s" % [_content_name("professions", String(offer.profession_id)), _content_name("traits", String(offer.trait_id))]
|
||
if not guild_company.is_empty() and String(offer.source_offer_id) not in guild_company.active_contract.available_recruit_offer_ids:
|
||
detail = "%s · %s" % [_tr("已招募", "HIRED"), detail]
|
||
return detail
|
||
"equipment": return _equipment_effect_by_id(String(offer.catalog_id))
|
||
"battle": return _tr("%d 波 · %d 名敌人", "%d waves · %d enemies") % [int(offer.get("wave_count", 0)), Array(offer.get("enemy_ids", [])).size()]
|
||
return ""
|
||
|
||
|
||
func _market_offer_price(kind: String, offer: Dictionary) -> String:
|
||
if kind == "battle":
|
||
return _tr("入场 0", "Entry 0")
|
||
if kind == "recruit" and not guild_company.is_empty() and String(offer.source_offer_id) not in guild_company.active_contract.available_recruit_offer_ids:
|
||
return _tr("已招募", "Hired")
|
||
return _tr("%d 金币", "%d Coin") % int(offer.get("list_price", 0))
|
||
|
||
|
||
func _market_context_detail(kind: String, offer: Dictionary) -> String:
|
||
match kind:
|
||
"recruit":
|
||
return "%s\n%s: %s\n%s: %s\n%s" % [_content_name("professions", String(offer.profession_id)), _tr("特质收益", "Trait benefit"), _trait_value(offer, "benefit"), _tr("特质代价", "Trait cost"), _trait_value(offer, "cost"), _attribute_summary(offer)]
|
||
"equipment":
|
||
var row: Dictionary = RuntimeContentRegistry.rows_by_id(market_content, "equipment").get(String(offer.catalog_id), {})
|
||
var locale := String(settings.get("locale", "zh_CN"))
|
||
return "%s\n%s: %s\n%s: %s" % [MarketLocalization.equipment_value(String(offer.catalog_id), "effect", String(row.get("effect", "")), locale), _tr("适用", "Fit"), MarketLocalization.equipment_value(String(offer.catalog_id), "fit", String(row.get("fit", "")), locale), _tr("取舍", "Tradeoff"), MarketLocalization.equipment_value(String(offer.catalog_id), "tradeoff", String(row.get("tradeoff", "")), locale)]
|
||
"battle":
|
||
var enemy_names: Array[String] = []
|
||
for enemy_id in offer.get("enemy_ids", []):
|
||
enemy_names.append(_content_name("enemies", String(enemy_id)))
|
||
var risk_copy := _tr("稳健压力", "steady pressure") if int(offer.get("risk", 0)) == 0 else _tr("加剧压力", "elevated pressure") if int(offer.get("risk", 0)) == 1 else _tr("高压路线", "high-pressure route")
|
||
return _tr("已知压力:%s\n敌方:%s\n选择路线不会在此页面开始战斗。", "Known pressure: %s\nEnemies: %s\nSelecting a route does not start battle from this page.") % [risk_copy, ", ".join(enemy_names)]
|
||
return ""
|
||
|
||
|
||
func _market_recruit_name(offer: Dictionary) -> String:
|
||
var tokens: Array = offer.get("display_name_tokens", [])
|
||
var parts: Array[String] = []
|
||
for token in tokens:
|
||
var segments := String(token).split(".")
|
||
parts.append(String(segments[segments.size() - 1]))
|
||
return " ".join(parts)
|
||
|
||
|
||
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 _contract_company_scene(recruits: Array) -> Control:
|
||
var stage := Control.new()
|
||
stage.name = "ContractCompanyScene"
|
||
stage.custom_minimum_size = Vector2(0, 150 if size.y < 500.0 else 220)
|
||
stage.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
stage.clip_contents = true
|
||
for index in range(mini(2, recruits.size())):
|
||
var portrait := TextureRect.new()
|
||
portrait.name = "ContractCompanyMember_%d" % index
|
||
portrait.texture = InitialCompanyArt.texture_for(recruits[index])
|
||
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 + index * 0.55
|
||
portrait.anchor_top = 0.02
|
||
portrait.anchor_right = 0.43 + index * 0.55
|
||
portrait.anchor_bottom = 1.0
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
stage.add_child(portrait)
|
||
var board = GuildDestinationSticker.new().configure("contract")
|
||
board.name = "ContractBoardSticker"
|
||
board.anchor_left = 0.39
|
||
board.anchor_top = 0.20
|
||
board.anchor_right = 0.61
|
||
board.anchor_bottom = 0.78
|
||
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
stage.add_child(board)
|
||
return stage
|
||
|
||
|
||
func _contract_segment_button(difficulty: String) -> Button:
|
||
var selected := difficulty == contract_difficulty
|
||
var node_name := "ContractDifficulty%s" % difficulty.to_pascal_case().replace("_", "")
|
||
var button := _entry_button_base(node_name, select_contract_difficulty.bind(difficulty), "content_primary" if selected else "content_secondary")
|
||
button.custom_minimum_size = Vector2(0, 44)
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
button.tooltip_text = _difficulty_copy(difficulty)
|
||
var label := _button_label("ButtonLabel", _difficulty_name(difficulty), _scaled(10), Color("#202a31") if selected else Color("#f5ead0"))
|
||
label.offset_left = 5
|
||
label.offset_right = -5
|
||
button.add_child(label)
|
||
button.set_meta("selected", selected)
|
||
return button
|
||
|
||
|
||
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 _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_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.")
|
||
"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:
|
||
for relation in party_preview.get("relations", []):
|
||
if String(relation.get("recruit_id", "")) == recruit_id:
|
||
return Dictionary(relation)
|
||
return {}
|
||
|
||
|
||
func _party_relation_summary_text(company: Dictionary) -> String:
|
||
var context_id := party_selected_recruit_id if not party_selected_recruit_id.is_empty() else String(party_draft_order[0])
|
||
var relation := _party_relation(context_id)
|
||
var target_copy := _tr("首攻", "TARGET 1") if bool(relation.get("first_threat_target", false)) else _tr("目标 %d", "TARGET %d") % int(relation.get("expected_target_rank", 1))
|
||
return _tr("%d 号\n%s", "POS %d\n%s") % [int(relation.get("position", 0)), target_copy]
|
||
|
||
|
||
func _party_threat_summary_text(first_threat: Dictionary) -> String:
|
||
var context_id := party_selected_recruit_id if not party_selected_recruit_id.is_empty() else String(party_draft_order[0])
|
||
var relation := _party_relation(context_id)
|
||
var protected_count: int = relation.get("protected_by_recruit_ids", []).size()
|
||
var protection_copy := _tr("无保护", "NO GUARD") if protected_count == 0 else _tr("保护 %d", "GUARD %d") % protected_count
|
||
return "%s\n%s\n%s" % [
|
||
_tr("%d 敌 · %d 波", "EN %d · WV %d") % [int(first_threat.get("enemy_count", 0)), int(first_threat.get("wave_count", 0))],
|
||
protection_copy,
|
||
_party_relation_reach_short(String(relation.get("reach_id", "limited"))),
|
||
]
|
||
|
||
|
||
func _party_relation_reach_short(reach_id: String) -> String:
|
||
match reach_id:
|
||
"full_line": return _tr("全队射程", "REACH ALL")
|
||
"front_three": return _tr("射程前 3", "REACH 3")
|
||
"front_two": return _tr("射程前 2", "REACH 2")
|
||
return _tr("射程受限", "LIMITED")
|
||
|
||
|
||
func _party_button_for_recruit(recruit_id: String) -> Button:
|
||
if recruit_id.is_empty():
|
||
return null
|
||
for position in range(party_draft_order.size()):
|
||
var button := find_child("PartyPosition%d" % position, true, false) as Button
|
||
if button != null and String(button.get_meta("recruit_id", "")) == recruit_id:
|
||
return button
|
||
return null
|
||
|
||
|
||
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 _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 _first_threat_text(threat: Dictionary) -> String:
|
||
return _tr("首要威胁 → 0 号前排 · %d 名敌人 · %d 波", "FIRST THREAT -> FRONT 0 · %d ENEMIES · %d WAVES") % [int(threat.get("enemy_count", 0)), int(threat.get("wave_count", 0))]
|
||
|
||
|
||
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 := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
button.tooltip_text = text_value
|
||
button.custom_minimum_size = Vector2(190, 52)
|
||
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 color := Color("#202a31") if variant in ["company_utility", "content_primary", "content_destination"] else Color("#f5ead0")
|
||
var label := _button_label("ButtonLabel", text_value, _scaled(16), color)
|
||
label.offset_left = 30.0
|
||
label.offset_right = -10.0
|
||
button.add_child(label)
|
||
button.set_meta("entry_chrome_variant", variant)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
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:
|
||
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 show_settings(category := "") -> void:
|
||
current_screen = "settings"
|
||
if settings_draft.is_empty() or not category.is_empty():
|
||
settings_draft = settings.duplicate(true)
|
||
if not category.is_empty():
|
||
settings_category = category
|
||
_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(110, 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(160, 44 if short_layout else 48)
|
||
footer.add_child(discard)
|
||
var apply := _action_button("SettingsApply", _tr("应用", "Apply"), apply_settings)
|
||
apply.custom_minimum_size = Vector2(118 if short_layout else 190, 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:
|
||
if settings_caller == "guild_handoff":
|
||
show_guild_handoff(guild_company)
|
||
elif settings_caller == "readiness":
|
||
show_readiness_review(guild_company)
|
||
elif settings_caller == "risk_board":
|
||
show_risk_board(guild_company)
|
||
elif settings_caller == "risk_commitment":
|
||
show_risk_commitment(guild_company)
|
||
elif settings_caller == "battle_opening":
|
||
show_battle_opening(guild_company, false)
|
||
elif settings_caller == "battle_live":
|
||
_battle_live_return_from_settings()
|
||
else:
|
||
back_to_title()
|
||
|
||
|
||
func apply_settings() -> void:
|
||
var saved: Dictionary = settings_store.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(SettingsStore.SAFE_AREA_PRESETS, int(settings_draft.safe_area))
|
||
show_settings()
|
||
|
||
|
||
func cycle_setting_text_scale() -> void:
|
||
settings_draft.text_scale = _next_float(SettingsStore.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_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 _build_party_master_base() -> void:
|
||
for child in get_children():
|
||
child.free()
|
||
var matte := ColorRect.new()
|
||
matte.name = "PartyMasterMatte"
|
||
matte.color = Color("#17252c")
|
||
matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
matte.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(matte)
|
||
var canvas := Control.new()
|
||
canvas.name = "PartyMasterCanvas"
|
||
canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(canvas)
|
||
party_master_design_canvas = canvas
|
||
_layout_party_master_canvas()
|
||
var master := TextureRect.new()
|
||
master.name = "PartyMasterImage"
|
||
var source := Image.load_from_file(ProjectSettings.globalize_path(PARTY_LINE_MASTER_PATH))
|
||
master.texture = ImageTexture.create_from_image(source)
|
||
master.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
master.stretch_mode = TextureRect.STRETCH_SCALE
|
||
master.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
master.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
canvas.add_child(master)
|
||
|
||
|
||
func _layout_party_master_canvas() -> void:
|
||
if not is_instance_valid(party_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)
|
||
party_master_design_canvas.position = (available - canvas_size) * 0.5
|
||
party_master_design_canvas.size = canvas_size
|
||
|
||
|
||
func _build_party_master_ui(resolved: Dictionary, formation: Dictionary) -> void:
|
||
var canvas := find_child("PartyMasterCanvas", true, false) as Control
|
||
if canvas == null:
|
||
return
|
||
var overlay := Control.new()
|
||
overlay.name = "PartyMasterOverlay"
|
||
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
overlay.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
canvas.add_child(overlay)
|
||
_party_master_label(overlay, "PartyTitle", Rect2(0.205, 0.045, 0.125, 0.05), _tr("队列编成", "PARTY LINE"), _party_master_text_size(10), Color("#242c2b"))
|
||
_party_master_label(
|
||
overlay,
|
||
"PartyPressureValue",
|
||
Rect2(0.415, 0.045, 0.095, 0.05),
|
||
_party_pressure_short(String(resolved.active_contract.get("difficulty", "standard"))),
|
||
_party_master_text_size(10),
|
||
Color("#514331")
|
||
)
|
||
_party_master_label(overlay, "PartyCompanyValue", Rect2(0.635, 0.045, 0.105, 0.05), "%d/4" % resolved.owned_recruits.size(), _party_master_text_size(10), Color("#242c2b"))
|
||
var diagram := PartyLineDiagram.new()
|
||
diagram.name = "PartyOrderDiagram"
|
||
_party_master_place(diagram, Rect2(0.775, 0.17, 0.185, 0.12))
|
||
overlay.add_child(diagram)
|
||
var cord_track := PartyLineTrack.new()
|
||
cord_track.name = "PartyFormationCord"
|
||
cord_track.render_layer = PartyLineTrack.RenderLayer.CORD
|
||
cord_track.z_index = 25
|
||
cord_track.set_meta("render_layer", "cord")
|
||
_party_master_place(cord_track, Rect2(0.10, 0.27, 0.72, 0.43))
|
||
cord_track.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(cord_track)
|
||
var member_areas := [
|
||
Rect2(0.590, 0.295, 0.105, 0.375),
|
||
Rect2(0.460, 0.325, 0.105, 0.345),
|
||
Rect2(0.330, 0.310, 0.105, 0.360),
|
||
Rect2(0.200, 0.310, 0.105, 0.360),
|
||
]
|
||
for visual_index in range(party_draft_order.size() - 1, -1, -1):
|
||
var recruit_id := String(party_draft_order[visual_index])
|
||
var recruit := _recruit_by_id(resolved, recruit_id)
|
||
var button := _party_master_member_button(visual_index, recruit, member_areas[visual_index])
|
||
button.z_index = 30
|
||
overlay.add_child(button)
|
||
var member_nodes: Array[Control] = []
|
||
for child in overlay.get_children():
|
||
if child is Button and String(child.name).begins_with("PartyPosition"):
|
||
member_nodes.append(child)
|
||
var effects_track := PartyLineTrack.new()
|
||
effects_track.name = "PartyFormationEffects"
|
||
effects_track.render_layer = PartyLineTrack.RenderLayer.EFFECTS
|
||
effects_track.z_index = 35
|
||
effects_track.set_meta("render_layer", "effects")
|
||
_party_master_place(effects_track, Rect2(0.10, 0.27, 0.72, 0.43))
|
||
effects_track.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
effects_track.bind(member_nodes, party_selected_recruit_id, String(settings.get("locale", "zh_CN")))
|
||
cord_track.bind(member_nodes, party_selected_recruit_id, String(settings.get("locale", "zh_CN")))
|
||
overlay.add_child(effects_track)
|
||
var relation := _label("", _party_master_text_size(10), Color("#2b241b"))
|
||
relation.name = "PartyRelationSummary"
|
||
relation.z_index = 40
|
||
relation.set_meta("design_font_size", 10)
|
||
_party_master_place(relation, Rect2(0.375, 0.665, 0.095, 0.105))
|
||
relation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
relation.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
relation.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
relation.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
relation.text = _party_relation_summary_text(resolved)
|
||
relation.tooltip_text = _recruit_name(_recruit_by_id(resolved, party_selected_recruit_id if not party_selected_recruit_id.is_empty() else String(party_draft_order[0])))
|
||
overlay.add_child(relation)
|
||
var first_threat: Dictionary = formation.get("first_threat", {})
|
||
var threat := _label(
|
||
_party_threat_summary_text(first_threat),
|
||
_party_master_text_size(10),
|
||
Color("#2b241b")
|
||
)
|
||
threat.name = "PartyFirstThreat"
|
||
threat.z_index = 40
|
||
threat.set_meta("design_font_size", 10)
|
||
threat.tooltip_text = _first_threat_text(first_threat)
|
||
_party_master_place(threat, Rect2(0.49, 0.665, 0.13, 0.105))
|
||
threat.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
threat.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
threat.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
threat.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
overlay.add_child(threat)
|
||
var orientation := Label.new()
|
||
orientation.name = "PartyOrientation"
|
||
orientation.text = _tr("后排在左 · 0 号前排在右", "REAR LEFT · FRONT POSITION 0 RIGHT")
|
||
orientation.visible = false
|
||
overlay.add_child(orientation)
|
||
var undo := _party_master_command_button(
|
||
"PartyUndo", _tr("撤销", "Undo"), undo_party_swap, Rect2(0.22, 0.85, 0.12, 0.15), party_undo_order.is_empty(),
|
||
_tr("还没有可撤销的位置交换", "No position swap to undo") if party_undo_order.is_empty() else _tr("撤销上一次位置交换", "Undo the last position swap"), "secondary"
|
||
)
|
||
overlay.add_child(undo)
|
||
var reset_disabled: bool = party_draft_order == formation.base_front_to_rear_ids
|
||
var reset := _party_master_command_button(
|
||
"PartyReset", _tr("重置", "Reset"), reset_party_order, Rect2(0.36, 0.85, 0.11, 0.15), reset_disabled,
|
||
_tr("当前已是系统初始顺序", "The automatic starting order is already restored") if reset_disabled else _tr("恢复系统初始顺序", "Restore the automatic starting order"), "secondary"
|
||
)
|
||
overlay.add_child(reset)
|
||
var save := _party_master_command_button(
|
||
"PartySaveReview", _tr("保存", "Save"), save_party_order, Rect2(0.48, 0.85, 0.15, 0.15), false,
|
||
_tr("保存当前顺序并打开准备检查", "Save this order and open Readiness Review"), "primary"
|
||
)
|
||
overlay.add_child(save)
|
||
var back := _party_master_command_button(
|
||
"PartyBack", _tr("返回", "Back"), cancel_party_line, Rect2(0.65, 0.85, 0.12, 0.15), false,
|
||
_tr("取消预览并返回公会", "Cancel the preview and return to Guild"), "secondary"
|
||
)
|
||
overlay.add_child(back)
|
||
var focus_nodes: Array[Control] = member_nodes.duplicate()
|
||
focus_nodes.append(undo)
|
||
focus_nodes.append(reset)
|
||
focus_nodes.append(save)
|
||
focus_nodes.append(back)
|
||
for index in range(focus_nodes.size()):
|
||
var current := focus_nodes[index]
|
||
var next := focus_nodes[(index + 1) % focus_nodes.size()]
|
||
var previous := focus_nodes[(index - 1 + focus_nodes.size()) % focus_nodes.size()]
|
||
current.focus_next = current.get_path_to(next)
|
||
current.focus_previous = current.get_path_to(previous)
|
||
var initial_focus := find_child("PartyPosition3", true, false) as Control
|
||
if initial_focus != null:
|
||
initial_focus.call_deferred("grab_focus")
|
||
|
||
|
||
func _party_master_member_button(position: int, recruit: Dictionary, area: Rect2) -> Button:
|
||
var button := Button.new()
|
||
button.name = "PartyPosition%d" % position
|
||
button.flat = true
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.set_meta("recruit_id", String(recruit.get("recruit_id", "")))
|
||
button.set_meta("position", position)
|
||
_party_master_place(button, area)
|
||
button.tooltip_text = _tr("%d 号位置 · %s · 点选后再点目标位置交换", "Position %d · %s · select, then choose a destination to swap") % [position, _recruit_name(recruit)]
|
||
button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||
button.add_theme_stylebox_override("hover", _party_master_member_style(true, true))
|
||
button.add_theme_stylebox_override("pressed", _party_master_member_style(true, true))
|
||
button.add_theme_stylebox_override("focus", _party_master_member_style(true, false))
|
||
button.add_theme_stylebox_override("hover_pressed", _party_master_member_style(true, true))
|
||
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.10
|
||
portrait.anchor_top = 0.0
|
||
portrait.anchor_right = 1.10
|
||
portrait.anchor_bottom = 0.90
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
button.add_child(portrait)
|
||
button.pressed.connect(select_party_member.bind(String(recruit.get("recruit_id", ""))), CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _party_master_command_button(node_name: String, text_value: String, callback: Callable, area: Rect2, disabled: bool, tooltip: String, variant: String = "secondary") -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = false
|
||
button.z_index = 40
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.disabled = disabled
|
||
button.set_meta("party_command_role", variant)
|
||
button.set_meta("party_command_disabled_emphasis", disabled)
|
||
button.tooltip_text = tooltip
|
||
_party_master_place(button, area)
|
||
button.add_theme_stylebox_override("normal", _party_master_command_state_style(false, true, false) if variant == "primary" else StyleBoxEmpty.new())
|
||
button.add_theme_stylebox_override("disabled", _party_master_command_state_style(false, false, true))
|
||
button.add_theme_stylebox_override("hover", _party_master_command_state_style(false, variant == "primary", false))
|
||
button.add_theme_stylebox_override("pressed", _party_master_command_state_style(true, variant == "primary", false))
|
||
button.add_theme_stylebox_override("focus", _party_master_command_state_style(false, variant == "primary", false))
|
||
button.add_theme_stylebox_override("hover_pressed", _party_master_command_state_style(true, variant == "primary", false))
|
||
var label := _button_label("ButtonLabel", text_value, _party_master_text_size(11), Color("#817867") if disabled else Color("#292c27"))
|
||
label.set_meta("design_font_size", 11)
|
||
label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||
label.anchor_left = 0.04
|
||
label.anchor_top = 0.54
|
||
label.anchor_right = 0.96
|
||
label.anchor_bottom = 0.99
|
||
label.offset_left = 0
|
||
label.offset_top = 0
|
||
label.offset_right = 0
|
||
label.offset_bottom = 0
|
||
button.add_child(label)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _party_pressure_short(difficulty: String) -> String:
|
||
match difficulty:
|
||
"wayfinder": return _tr("引路", "WAY")
|
||
"stormbound": return _tr("风暴", "STORM")
|
||
"iron_oath": return _tr("铁誓", "OATH")
|
||
return _tr("标准", "STD")
|
||
|
||
|
||
func _party_master_label(parent: Control, node_name: String, area: Rect2, text_value: String, font_size: int, color: Color) -> Label:
|
||
var label := _label(text_value, font_size, color)
|
||
label.name = node_name
|
||
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
|
||
_party_master_place(label, area)
|
||
parent.add_child(label)
|
||
return label
|
||
|
||
|
||
func _party_master_place(control: Control, area: Rect2) -> void:
|
||
control.anchor_left = area.position.x
|
||
control.anchor_top = area.position.y
|
||
control.anchor_right = area.end.x
|
||
control.anchor_bottom = area.end.y
|
||
control.offset_left = 0
|
||
control.offset_top = 0
|
||
control.offset_right = 0
|
||
control.offset_bottom = 0
|
||
|
||
|
||
func _party_master_text_size(design_size: int) -> int:
|
||
var canvas_height := party_master_design_canvas.size.y if is_instance_valid(party_master_design_canvas) else size.y
|
||
var canvas_scale := clampf(canvas_height / 360.0, 1.0, 2.0)
|
||
var text_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
# The contained canvas can grow to 720p, but the authored parchment slots do
|
||
# not grow with it. Keep localized copy readable without pushing it outside
|
||
# the paper plate or command label strip at 130% text scale.
|
||
return mini(16, maxi(design_size, int(round(float(design_size) * canvas_scale * text_scale))))
|
||
|
||
|
||
func _party_master_member_style(selected: bool, hovered: bool) -> StyleBox:
|
||
if not selected:
|
||
return StyleBoxEmpty.new()
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = Color(0.96, 0.83, 0.35, 0.08 if not hovered else 0.14)
|
||
style.border_color = Color(1.0, 0.90, 0.48, 0.92)
|
||
style.set_border_width_all(2)
|
||
style.set_corner_radius_all(10)
|
||
return style
|
||
|
||
|
||
func _party_master_command_state_style(pressed: bool, primary: bool = false, disabled: bool = false) -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
if disabled:
|
||
style.bg_color = Color(0.20, 0.24, 0.23, 0.30)
|
||
style.border_color = Color(0.35, 0.37, 0.33, 0.32)
|
||
else:
|
||
style.bg_color = Color(0.28, 0.52, 0.32, (0.38 if pressed else 0.30) if primary else (0.20 if pressed else 0.12))
|
||
style.border_color = Color(0.92, 0.76, 0.34, 0.96 if primary else 0.88)
|
||
style.set_border_width_all(4 if primary else 2)
|
||
style.set_corner_radius_all(6)
|
||
return style
|
||
|
||
|
||
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
|
||
button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||
button.add_theme_stylebox_override("hover", _flat_style(Color(1.0, 0.91, 0.65, 0.12), Color(0, 0, 0, 0), 0, 5))
|
||
button.add_theme_stylebox_override("pressed", _flat_style(Color(0.20, 0.48, 0.65, 0.17), Color(0, 0, 0, 0), 0, 5))
|
||
button.add_theme_stylebox_override("focus", _flat_style(Color(0.20, 0.58, 0.78, 0.13), Color(0, 0, 0, 0), 0, 5))
|
||
button.add_theme_stylebox_override("hover_pressed", _flat_style(Color(0.20, 0.48, 0.65, 0.21), Color(0, 0, 0, 0), 0, 5))
|
||
button.add_theme_stylebox_override("disabled", _flat_style(Color(0.04, 0.08, 0.10, 0.30), Color(0.24, 0.27, 0.28, 0.55), 1, 5))
|
||
_set_normalized_rect(button, rect)
|
||
button.set_meta("title_master_kind", kind)
|
||
var label := _label(title, _scaled(15 if kind == "primary" else 12 if kind == "secondary" else 9), Color("#263a43"))
|
||
label.name = "ButtonLabel"
|
||
_set_normalized_rect(label, Rect2(0.06, 0.16 if kind != "utility" else 0.08, 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)
|
||
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 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(190, 52)
|
||
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(112, 48)
|
||
var label := _button_label("ButtonLabel", text_value, _scaled(14), Color("#f5ead0"))
|
||
label.offset_left = 16.0
|
||
label.offset_right = -10.0
|
||
button.add_child(label)
|
||
return button
|
||
|
||
|
||
func _check_button(node_name: String, text_value: String, enabled: bool, callback: Callable) -> CheckButton:
|
||
var check := CheckButton.new()
|
||
check.name = node_name
|
||
check.button_pressed = enabled
|
||
check.custom_minimum_size = Vector2(0, 48)
|
||
check.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
check.flat = true
|
||
for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]:
|
||
check.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
check.add_child(_entry_chrome("Chrome", "toggle"))
|
||
var label := _button_label("ButtonLabel", text_value, _scaled(14), Color("#f5ead0"))
|
||
label.offset_left = 14.0
|
||
label.offset_right = -52.0
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
check.add_child(label)
|
||
check.toggled.connect(callback)
|
||
return check
|
||
|
||
|
||
func _entry_button_base(node_name: String, callback: Callable, variant: String) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.flat = true
|
||
for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]:
|
||
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
|
||
button.add_child(_entry_chrome("Chrome", variant))
|
||
button.set_meta("entry_chrome_variant", variant)
|
||
button.pressed.connect(callback, CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func _button_label(node_name: String, text_value: String, font_size: int, color: Color) -> Label:
|
||
var label := _label(text_value, font_size, color)
|
||
label.name = node_name
|
||
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return label
|
||
|
||
|
||
func _set_entry_button_disabled(button: Button, disabled: bool, selected := false) -> void:
|
||
button.disabled = disabled
|
||
for child_name in ["ButtonLabel", "ChoiceLabel", "ValueLabel"]:
|
||
var label := button.get_node_or_null(child_name) as Label
|
||
if label == null:
|
||
continue
|
||
if selected:
|
||
label.add_theme_color_override("font_color", Color("#202a31"))
|
||
elif disabled:
|
||
label.add_theme_color_override("font_color", Color("#4f565b") if child_name == "ValueLabel" else Color("#89939a"))
|
||
elif child_name == "ValueLabel":
|
||
label.add_theme_color_override("font_color", Color("#202a31"))
|
||
else:
|
||
label.add_theme_color_override("font_color", Color("#f5ead0"))
|
||
|
||
|
||
func _set_entry_button_text(button: Button, text_value: String) -> void:
|
||
var label := button.get_node_or_null("ButtonLabel") as Label
|
||
if label != null:
|
||
label.text = text_value
|
||
button.tooltip_text = text_value
|
||
|
||
|
||
func _flat_style(background: Color, border: Color, width: int, radius: int) -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = background
|
||
style.border_color = border
|
||
style.set_border_width_all(width)
|
||
style.set_corner_radius_all(radius)
|
||
return style
|
||
|
||
|
||
func _label(text_value: String, font_size: int, color: Color) -> Label:
|
||
var label := Label.new()
|
||
label.text = text_value
|
||
label.add_theme_font_override("font", UI_FONT)
|
||
label.add_theme_font_size_override("font_size", font_size)
|
||
label.add_theme_color_override("font_color", color)
|
||
return label
|
||
|
||
|
||
func _tr(zh: String, en: String) -> String:
|
||
var locale := String(setup_draft.get("locale", settings.get("locale", "zh_CN")))
|
||
return zh if locale == "zh_CN" else en
|
||
|
||
|
||
func _guild_text_size(design_size: int) -> int:
|
||
var canvas_height := guild_master_design_canvas.size.y if is_instance_valid(guild_master_design_canvas) else size.y
|
||
var height_ratio := clampf(canvas_height / 360.0, 1.0, 2.0)
|
||
var canvas_scale := lerpf(1.0, height_ratio, 0.35)
|
||
var text_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
return maxi(7, int(round(float(design_size) * canvas_scale * text_scale)))
|
||
|
||
|
||
func _scaled(value: int) -> int:
|
||
var scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0)))
|
||
return int(round(float(value) * scale))
|
||
|
||
|
||
func _canonical_value(value):
|
||
if value is float and is_equal_approx(value, round(value)):
|
||
return int(round(value))
|
||
if value is Dictionary:
|
||
var result := {}
|
||
var keys: Array = value.keys()
|
||
keys.sort_custom(func(left, right): return String(left) < String(right))
|
||
for key in keys:
|
||
result[key] = _canonical_value(value[key])
|
||
return result
|
||
if value is Array:
|
||
var result: Array = []
|
||
for item in value:
|
||
result.append(_canonical_value(item))
|
||
return result
|
||
return value
|