Files
aetherbound-guild/runtime/entry/views/contract_choice_page_view.gd
T

317 lines
15 KiB
GDScript

class_name ABGContractChoicePageView
extends "res://presentation/page_view.gd"
## UI-only Contract Choice View.
##
## The View owns the framed composition, Company portraits, local difficulty
## selection controls, focus and explicit intents. The supplied snapshot is
## already copied and presentation-ready; application decisions stay with
## the composition root.
const CONTENT_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png")
const M52_CONTRACT_BACKGROUND = preload("res://assets/backgrounds/m52_contract_background.png")
# M69: character-free world master (the painted duo was removed so the real
# Company pair can stand in the authored spot). Old master kept for provenance.
const M52_Q_CONTRACT_BACKGROUND = preload("res://assets/backgrounds/m69_contract_world.png")
const InitialCompanyArt = preload("res://entry/initial_company_art.gd")
const GuildDestinationSticker = preload("res://entry/guild_destination_sticker.gd")
const M54_CONTRACT_HEADER = preload("res://ui/m54/contract_header.gd")
const M54_CONTRACT_BOARD = preload("res://ui/m54/contract_board.gd")
const M54_CONTRACT_PARTY_SUMMARY = preload("res://ui/m54/contract_party_summary.gd")
const M54_CONTRACT_ACTION_DOCK = preload("res://ui/m54/contract_action_dock.gd")
const M54_CONTRACT_NAVIGATION = preload("res://ui/m54/contract_navigation.gd")
const PageChrome = preload("res://ui/shared/page_chrome.gd")
const UITokens = preload("res://foundation/ui_tokens.gd")
func _render_view(data: Dictionary) -> void:
clear_view_children()
_build_world_base()
if not bool(data.get("ok", false)):
_render_invalid(data)
return
var frame_safe := make_framed_page_safe("ContractChoice")
# M65-C: the shared parchment card owns the frame; the authored node name is
# kept for the page contract.
# The Q route illustration owns the scene, so the shared card only acts as a
# dimmed vignette; the header band and review column carry the copy.
var frame_chrome := PageChrome.new().configure("card")
frame_chrome.name = "ContractChoiceFrame"
frame_chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
frame_chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
# The illustration is the page identity; keep the named frame as an
# invisible compatibility shell (the M63 pattern) instead of covering it.
frame_chrome.modulate.a = 0.0
frame_safe.add_child(frame_chrome)
var safe := make_content_page_safe("ContractChoice")
var layout := VBoxContainer.new()
layout.name = "ContractChoiceLayout"
layout.add_theme_constant_override("separation", 6)
safe.add_child(layout)
var company_data: Dictionary = Dictionary(data.get("company", {}))
var contract_data: Dictionary = Dictionary(data.get("contract", {}))
var selected_difficulty := String(data.get("selected_difficulty", "standard"))
var difficulties: Array = Array(data.get("difficulties", []))
var header_host := Control.new()
header_host.name = "ContractHeaderHost"
header_host.custom_minimum_size = Vector2(0, 68)
layout.add_child(header_host)
var header_band := PageChrome.new().configure("header_band")
header_band.name = "ContractHeaderBand"
header_band.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
header_band.mouse_filter = Control.MOUSE_FILTER_IGNORE
header_host.add_child(header_band)
var header := M54_CONTRACT_HEADER.new().configure(
localize("委托板", "CONTRACT BOARD", data),
localize("风镜原野 · 首次远征", "WINDGLASS VERGE · FIRST EXPEDITION", data),
localize("远征团 %d · 持续保留", "COMPANY %d · PERSISTENT", data) % int(company_data.get("owned_count", 0))
)
header.name = "ContractHeader"
header.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# Seat the copy on the band face (measured rolled edges).
header.offset_left = 1280.0 * PageChrome.PANEL_EDGE_X * 0.5
header.offset_right = -1280.0 * PageChrome.PANEL_EDGE_X * 0.5
header.offset_top = 68.0 * PageChrome.PANEL_EDGE_Y * 0.5
header.offset_bottom = 0.0
header_host.add_child(header)
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)
var board := M54_CONTRACT_BOARD.new().configure(
String(contract_data.get("route_title", localize("风镜初渡", "FIRST WINDGLASS CROSSING", data))),
String(contract_data.get("route_copy", localize("穿越悬浮风镜,建立公会的第一条安全通路。", "Cross the suspended windglass and establish the Guild's first safe route.", data))),
Array(company_data.get("recruits", []))
)
board.name = "ContractBoard"
board.size_flags_vertical = Control.SIZE_EXPAND_FILL
scene.add_child(board)
# Preserve stable legacy nodes for the View contract while the new board owns
# the visible party composition.
var legacy_scene := _contract_company_scene(Array(company_data.get("recruits", [])))
legacy_scene.name = "ContractCompanyScene"
# M69: the stage is a page-level overlay so the real Company pair stands in
# the authored lower-left spot of the (now character-free) route master.
legacy_scene.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
legacy_scene.mouse_filter = Control.MOUSE_FILTER_IGNORE
legacy_scene.z_index = 5
safe.add_child(legacy_scene)
var review_host := Control.new()
review_host.name = "ContractReviewHost"
review_host.custom_minimum_size = Vector2(minf(360.0, size.x * 0.42), 300.0)
review_host.size_flags_vertical = Control.SIZE_SHRINK_CENTER
body.add_child(review_host)
var review_card := PageChrome.new().configure("card")
review_card.name = "ContractReviewCard"
review_card.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
review_card.mouse_filter = Control.MOUSE_FILTER_IGNORE
# The authored route board already is the parchment surface; keep the named
# card node for the page contract without stacking a second sheet.
review_card.modulate.a = 0.0
review_host.add_child(review_card)
var review := VBoxContainer.new()
review.name = "ContractReview"
review.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
review.offset_left = 12.0
review.offset_right = -12.0
review.offset_top = 12.0
review.offset_bottom = -12.0
review.add_theme_constant_override("separation", 5)
review_host.add_child(review)
review.add_child(make_label(localize("选择难度", "CHOOSE DIFFICULTY", data), scaled(11), PageChrome.INK_SOFT))
var segments := HBoxContainer.new()
segments.name = "ContractDifficultySegments"
segments.custom_minimum_size = Vector2(0, 48)
segments.add_theme_constant_override("separation", 3)
review.add_child(segments)
for option_value in difficulties:
if option_value is Dictionary:
segments.add_child(_contract_segment_button(Dictionary(option_value), selected_difficulty, data))
var selected_option := _difficulty_option(difficulties, selected_difficulty, data)
var difficulty_copy := make_label(String(selected_option.get("copy", "")), scaled(11), PageChrome.INK)
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 market_counts: Dictionary = Dictionary(contract_data.get("market_counts", {}))
var facts_text := localize(
"建立后:%d 金币 · 队列容量 %d\n市场:%d 名招募 · %d 件装备 · %d 条战斗路线",
"On creation: %d Coin · Line capacity %d\nMarket: %d Recruits · %d equipment · %d battle routes",
data
) % [
int(contract_data.get("starting_coin", 0)),
int(contract_data.get("line_capacity", 0)),
int(market_counts.get("recruit", 0)),
int(market_counts.get("equipment", 0)),
int(market_counts.get("battle", 0)),
]
var party_summary := M54_CONTRACT_PARTY_SUMMARY.new().configure(facts_text)
party_summary.name = "ContractPartySummary"
# M62-I04: the shipped two-line fact block needs 56px at label size; the old
# 48px slot silently trimmed it.
party_summary.custom_minimum_size = Vector2(0, 56)
review.add_child(party_summary)
var notice := make_label(String(data.get("notice", "")), scaled(10), UITokens.COLOR_DANGER)
notice.name = "ContractNotice"
notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
review.add_child(notice)
var footer := M54_CONTRACT_ACTION_DOCK.new().configure(
localize("返回公会", "Back to Guild", data),
localize("建立委托", "Create Contract", data),
bool(Dictionary(data.get("confirm", {})).get("eligible", false))
)
footer.name = "ContractChoiceFooter"
layout.add_child(footer)
footer.action_pressed.connect(func(intent_id: String): emit_intent(intent_id))
var navigation := M54_CONTRACT_NAVIGATION.new().configure(localize("返回公会", "Back to Guild", data))
navigation.name = "ContractNavigation"
navigation.visible = false
layout.add_child(navigation)
var selected_index := _difficulty_index(difficulties, selected_difficulty)
if selected_index >= 0 and selected_index < segments.get_child_count():
segments.get_child(selected_index).call_deferred("grab_focus")
func _build_world_base() -> void:
var background := TextureRect.new()
background.name = "WorldBackground"
background.texture = M52_Q_CONTRACT_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)
shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(shade)
func _contract_company_scene(recruits: Array) -> Control:
var stage := Control.new()
stage.name = "ContractCompanyScene"
stage.clip_contents = false
for index in range(mini(2, recruits.size())):
var recruit: Dictionary = Dictionary(recruits[index])
var portrait := TextureRect.new()
portrait.name = "ContractCompanyMember_%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
# The generated route already owns the scenic composition. Keep the
# runtime Company portraits in compact, non-overlapping slots with a
# common baseline so they do not cover the route board or each other.
# Page-relative spots where the authored duo used to stand.
portrait.anchor_left = 0.035 + float(index) * 0.135
portrait.anchor_top = 0.42
portrait.anchor_right = 0.185 + float(index) * 0.135
portrait.anchor_bottom = 0.95
# M69: the world master is character-free and the identity-locked soft
# Q sprite set has shipped, so the stable runtime member nodes own the
# two authored expedition spots.
portrait.visible = true
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
# The Q route background already contains the authored parchment destination
# board; retain this node for the contract shape but avoid drawing a second
# opaque sticker over the illustration.
board.visible = false
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
stage.add_child(board)
return stage
func _contract_segment_button(option: Dictionary, selected_difficulty: String, data: Dictionary) -> Button:
var difficulty := String(option.get("id", "standard"))
var selected := bool(option.get("selected", difficulty == selected_difficulty))
var node_name := "ContractDifficulty%s" % difficulty.to_pascal_case().replace("_", "")
var button := Button.new()
button.name = node_name
button.flat = true
button.custom_minimum_size = Vector2(0, 48)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.tooltip_text = String(option.get("copy", ""))
button.focus_mode = Control.FOCUS_ALL
for style_name in ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed"]:
button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
var chrome := PageChrome.new().configure("command_plate", {"state": "primary" if selected else "secondary"})
chrome.name = "Chrome"
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(chrome)
var label := make_label(String(option.get("name", difficulty)), scaled(10), PageChrome.INK)
label.name = "ButtonLabel"
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
label.offset_left = 5.0
label.offset_right = -5.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
button.add_child(label)
button.set_meta("selected", selected)
button.set_meta("difficulty", difficulty)
button.pressed.connect(func(): emit_intent("select_difficulty", {"difficulty": difficulty}), CONNECT_DEFERRED)
return button
func _difficulty_option(options: Array, selected_difficulty: String, data: Dictionary) -> Dictionary:
for option_value in options:
if option_value is Dictionary and String(option_value.get("id", "")) == selected_difficulty:
return Dictionary(option_value)
return {
"id": selected_difficulty,
"name": selected_difficulty,
"copy": localize("请选择一个有效难度。", "Choose a valid difficulty.", data),
}
func _difficulty_index(options: Array, selected_difficulty: String) -> int:
for index in range(options.size()):
if options[index] is Dictionary and String(options[index].get("id", "")) == selected_difficulty:
return index
return -1
func _render_invalid(data: Dictionary) -> void:
var safe := make_content_page_safe("ContractChoice")
var invalid := VBoxContainer.new()
invalid.name = "ContractChoiceInvalidOverlay"
invalid.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
invalid.custom_minimum_size = Vector2(420, 120)
invalid.add_theme_constant_override("separation", 8)
safe.add_child(invalid)
var title := make_label(localize("委托不可用", "CONTRACT CHOICE UNAVAILABLE", data), scaled(20), PageChrome.INK)
title.name = "ContractChoiceInvalidTitle"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
invalid.add_child(title)
var detail := make_label(String(data.get("notice", data.get("code", ""))), scaled(11), UITokens.COLOR_DANGER)
detail.name = "ContractChoiceInvalidDetail"
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
invalid.add_child(detail)