269 lines
11 KiB
GDScript
269 lines
11 KiB
GDScript
class_name ABGM52CandidateLedger
|
||
extends Control
|
||
|
||
## Standalone candidate detail ledger. It owns only presentation and emits a
|
||
## single action intent; Market remains responsible for routing and services.
|
||
|
||
const MarketChrome = preload("res://ui/shared/market_chrome.gd")
|
||
const UIFactory = preload("res://ui/shared/ui_factory.gd")
|
||
const PortraitModule = preload("res://ui/m52/portrait_module.gd")
|
||
const IdentityModule = preload("res://ui/m52/identity_module.gd")
|
||
const TraitModule = preload("res://ui/m52/trait_module.gd")
|
||
const CostModule = preload("res://ui/m52/cost_module.gd")
|
||
const ProfessionModule = preload("res://ui/m52/profession_module.gd")
|
||
const DetailCopy = preload("res://ui/m52/detail_copy.gd")
|
||
const ActionDock = preload("res://ui/m52/action_dock.gd")
|
||
|
||
signal action_requested
|
||
|
||
var candidate_name := ""
|
||
var profession_name := ""
|
||
var trait_name := ""
|
||
var availability := ""
|
||
var cost_text := ""
|
||
var detail_text := ""
|
||
var portrait_texture: Texture2D
|
||
var header_text := "候选人详情"
|
||
var empty_copy := "从左侧选择一名候选人。"
|
||
var action_node_name := "ActionButton"
|
||
var action_text := "查看详情"
|
||
var action_disabled := true
|
||
var detail_rows := 1
|
||
var text_scale := 1.0
|
||
|
||
func configure(data: Dictionary) -> ABGM52CandidateLedger:
|
||
_candidate_from(data)
|
||
return self
|
||
|
||
func _candidate_from(data: Dictionary) -> void:
|
||
candidate_name = String(data.get("name", "选择候选人"))
|
||
profession_name = String(data.get("profession", ""))
|
||
trait_name = String(data.get("trait", ""))
|
||
availability = String(data.get("availability", ""))
|
||
cost_text = String(data.get("cost", ""))
|
||
detail_text = String(data.get("detail", ""))
|
||
portrait_texture = data.get("portrait") as Texture2D
|
||
header_text = String(data.get("header", "候选人详情"))
|
||
empty_copy = String(data.get("empty_copy", "从左侧选择一名候选人。"))
|
||
action_node_name = String(data.get("action_node_name", "ActionButton"))
|
||
action_text = String(data.get("action_text", "查看详情"))
|
||
action_disabled = bool(data.get("action_disabled", portrait_texture == null))
|
||
detail_rows = maxi(1, int(data.get("detail_rows", 1)))
|
||
text_scale = float(data.get("text_scale", 1.0))
|
||
|
||
func _ready() -> void:
|
||
clip_contents = true
|
||
mouse_filter = Control.MOUSE_FILTER_PASS
|
||
_build()
|
||
_apply_responsive_layout()
|
||
|
||
func _notification(what: int) -> void:
|
||
if what == NOTIFICATION_RESIZED and is_inside_tree():
|
||
_apply_responsive_layout()
|
||
|
||
func _build() -> void:
|
||
var shell := MarketChrome.new().configure("ledger")
|
||
shell.name = "LedgerShell"
|
||
shell.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
shell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(shell)
|
||
|
||
var header := UIFactory.label(header_text, "label", 1.0, MarketChrome.PAPER_LIGHT)
|
||
header.name = "LedgerHeader"
|
||
header.add_theme_font_size_override("font_size", ABGUI_Tokens.font_size("body", text_scale))
|
||
_place(header, Rect2(0.08, 0.035, 0.84, 0.095))
|
||
header.z_index = 5
|
||
header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
header.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
header.clip_text = true
|
||
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(header)
|
||
|
||
var portrait := PortraitModule.new().configure(portrait_texture)
|
||
portrait.name = "PortraitModule"
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_place(portrait, Rect2(0.19, 0.21, 0.62, 0.28))
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(portrait)
|
||
|
||
var identity := IdentityModule.new().configure(candidate_name)
|
||
identity.name = "IdentityModule"
|
||
identity.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_place(identity, Rect2(0.16, 0.515, 0.68, 0.105))
|
||
identity.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(identity)
|
||
|
||
var profession := ProfessionModule.new().configure(profession_name, text_scale)
|
||
profession.name = "ProfessionModule"
|
||
profession.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_place(profession, Rect2(0.17, 0.635, 0.66, 0.055))
|
||
profession.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(profession)
|
||
var detail_value := detail_text if not detail_text.is_empty() else (empty_copy if portrait_texture == null else "")
|
||
var detail := DetailCopy.new().configure(detail_value, text_scale)
|
||
detail.name = "DetailCopy"
|
||
_place(detail, Rect2(0.06, 0.692, 0.88, 0.125 if detail_rows > 1 else 0.062))
|
||
detail.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(detail)
|
||
if portrait_texture == null:
|
||
var empty_marker := Control.new()
|
||
empty_marker.name = "MarketNoSelection"
|
||
empty_marker.visible = false
|
||
empty_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(empty_marker)
|
||
|
||
var trait_module := TraitModule.new().configure(trait_name, text_scale)
|
||
trait_module.name = "TraitModule"
|
||
trait_module.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
# A two-row detail (equipment/route facts) owns the trait row; the trait
|
||
# module is empty for those kinds and must not overlap the taller slot.
|
||
trait_module.visible = detail_rows <= 1 or not trait_name.is_empty()
|
||
_place(trait_module, Rect2(0.18, 0.755, 0.64, 0.065))
|
||
trait_module.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(trait_module)
|
||
|
||
var cost := CostModule.new().configure(availability, cost_text, text_scale)
|
||
cost.name = "CostModule"
|
||
cost.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_place(cost, Rect2(0.12, 0.825, 0.76, 0.065))
|
||
cost.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(cost)
|
||
|
||
var action := ActionDock.new().configure(action_text, action_disabled)
|
||
action.name = action_node_name
|
||
action.pressed.connect(_on_action_pressed, CONNECT_DEFERRED)
|
||
_place(action, Rect2(0.15, 0.905, 0.70, 0.085))
|
||
action.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
add_child(action)
|
||
|
||
func _apply_responsive_layout() -> void:
|
||
# The phone-landscape ledger is only ~240–280px wide and ~300–330px tall
|
||
# after the 16:9 canvas is fitted. Treat that whole range as compact so the
|
||
# 48px action dock is laid out before the shell's bottom edge.
|
||
# `size` is now authored 1280x720 units. Decide compactness from the
|
||
# transformed play-size so a phone-landscape ledger still gets its 48px
|
||
# action dock layout.
|
||
var play_size := get_global_rect().size
|
||
var compact := play_size.x < 300.0 or play_size.y < 380.0
|
||
# M73: 130% text needs taller text rows; the portrait yields the space.
|
||
var large_text := text_scale > 1.15
|
||
var portrait := get_node_or_null("PortraitModule") as Control
|
||
var identity := get_node_or_null("IdentityModule") as Control
|
||
var profession := get_node_or_null("ProfessionModule") as Control
|
||
var detail := get_node_or_null("DetailCopy") as Control
|
||
var trait_module := get_node_or_null("TraitModule") as Control
|
||
var cost := get_node_or_null("CostModule") as Control
|
||
var action := get_node_or_null("ActionButton") as Control
|
||
if action == null:
|
||
action = get_node_or_null(action_node_name) as Control
|
||
var header := get_node_or_null("LedgerHeader") as Label
|
||
if not compact and large_text:
|
||
if header != null:
|
||
_place(header, Rect2(0.04, 0.02, 0.92, 0.21))
|
||
header.add_theme_font_size_override("font_size", ABGUI_Tokens.font_size("body", text_scale))
|
||
# The EN section title is wider than the card face at 130%; wrap it
|
||
# to a second authored row instead of shrinking the token.
|
||
header.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
header.max_lines_visible = 2
|
||
if portrait != null:
|
||
_place(portrait, Rect2(0.24, 0.24, 0.52, 0.14))
|
||
if identity != null:
|
||
_place(identity, Rect2(0.06, 0.395, 0.88, 0.13))
|
||
if identity.has_method("set_compact"):
|
||
identity.set_compact(false)
|
||
if profession != null:
|
||
_place(profession, Rect2(0.10, 0.53, 0.80, 0.09))
|
||
if detail != null:
|
||
_place(detail, Rect2(0.06, 0.625, 0.88, 0.10 if detail_rows <= 1 else 0.16))
|
||
var large_detail_label := detail.get_node_or_null("DetailLabel") as Label
|
||
if large_detail_label != null:
|
||
large_detail_label.add_theme_font_size_override("font_size", ABGUI_Tokens.font_size("micro", text_scale))
|
||
large_detail_label.max_lines_visible = detail_rows
|
||
if trait_module != null:
|
||
_place(trait_module, Rect2(0.08, 0.735, 0.84, 0.10))
|
||
if trait_module.has_method("set_compact"):
|
||
trait_module.set_compact(false)
|
||
if cost != null:
|
||
# M73: the availability + amount pair needs the full card face at 130%.
|
||
_place(cost, Rect2(0.02, 0.845, 0.96, 0.10))
|
||
if cost.has_method("set_compact"):
|
||
cost.set_compact(false, true)
|
||
if action != null:
|
||
_place(action, Rect2(0.15, 0.90, 0.70, 0.09))
|
||
return
|
||
if not compact:
|
||
if header != null:
|
||
_place(header, Rect2(0.08, 0.035, 0.84, 0.095))
|
||
header.add_theme_font_size_override("font_size", ABGUI_Tokens.font_size("body", text_scale))
|
||
if portrait != null:
|
||
_place(portrait, Rect2(0.19, 0.21, 0.62, 0.28))
|
||
if identity != null:
|
||
_place(identity, Rect2(0.16, 0.515, 0.68, 0.105))
|
||
if identity.has_method("set_compact"):
|
||
identity.set_compact(false)
|
||
if profession != null:
|
||
_place(profession, Rect2(0.17, 0.635, 0.66, 0.055))
|
||
if detail != null:
|
||
# The context row is a caption row between the profession and the
|
||
# trait chip. At the canvas type scale the longest shipped string
|
||
# needs this width; 0.66 clipped it with an ellipsis. Equipment and
|
||
# route facts are longer, so those kinds author two rows.
|
||
_place(detail, Rect2(0.06, 0.692, 0.88, 0.125 if detail_rows > 1 else 0.062))
|
||
var wide_detail_label := detail.get_node_or_null("DetailLabel") as Label
|
||
if wide_detail_label != null:
|
||
wide_detail_label.add_theme_font_size_override("font_size", ABGUI_Tokens.font_size("micro", text_scale))
|
||
wide_detail_label.max_lines_visible = detail_rows
|
||
if trait_module != null:
|
||
_place(trait_module, Rect2(0.18, 0.755, 0.64, 0.065))
|
||
if trait_module.has_method("set_compact"):
|
||
trait_module.set_compact(false)
|
||
if cost != null:
|
||
_place(cost, Rect2(0.12, 0.825, 0.76, 0.065))
|
||
if cost.has_method("set_compact"):
|
||
# The ledger is the surface that carries the full availability and
|
||
# currency string; only the card row drops the availability label.
|
||
cost.set_compact(false, true)
|
||
if action != null:
|
||
_place(action, Rect2(0.15, 0.905, 0.70, 0.085))
|
||
return
|
||
if header != null:
|
||
_place(header, Rect2(0.10, 0.035, 0.80, 0.095))
|
||
header.add_theme_font_size_override("font_size", 14)
|
||
if portrait != null:
|
||
_place(portrait, Rect2(0.10, 0.16, 0.80, 0.25))
|
||
if identity != null:
|
||
_place(identity, Rect2(0.14, 0.42, 0.72, 0.11))
|
||
if identity.has_method("set_compact"):
|
||
identity.set_compact(true)
|
||
if profession != null:
|
||
_place(profession, Rect2(0.14, 0.53, 0.72, 0.05))
|
||
if detail != null:
|
||
_place(detail, Rect2(0.14, 0.585, 0.72, 0.115 if detail_rows > 1 else 0.055))
|
||
var detail_label := detail.get_node_or_null("DetailLabel") as Label
|
||
if detail_label != null:
|
||
detail_label.add_theme_font_size_override("font_size", 11)
|
||
detail_label.max_lines_visible = 2 if detail_rows > 1 else 2
|
||
if trait_module != null:
|
||
_place(trait_module, Rect2(0.16, 0.65, 0.68, 0.065))
|
||
if trait_module.has_method("set_compact"):
|
||
trait_module.set_compact(true)
|
||
if cost != null:
|
||
_place(cost, Rect2(0.10, 0.73, 0.80, 0.07))
|
||
if cost.has_method("set_compact"):
|
||
cost.set_compact(true, true)
|
||
if action != null:
|
||
_place(action, Rect2(0.10, 0.82, 0.80, 0.14))
|
||
|
||
func _on_action_pressed() -> void:
|
||
action_requested.emit()
|
||
|
||
func _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.0
|
||
control.offset_top = 0.0
|
||
control.offset_right = 0.0
|
||
control.offset_bottom = 0.0
|