Files
aetherbound-guild/runtime/presentation/page_view.gd
T

261 lines
9.6 KiB
GDScript

class_name ABGPageView
extends Control
const EntryChrome = preload("res://entry/entry_chrome.gd")
const UI_FONT = preload("res://assets/fonts/NotoSansSC.ttf")
const PageContext = preload("res://foundation/page_context.gd")
const UIFactory = preload("res://ui/shared/ui_factory.gd")
const Tokens = preload("res://foundation/ui_tokens.gd")
## Passive View base for the new Entry page boundary.
##
## A PageView owns Godot nodes, layout, focus and local visual state. It
## receives a copied ViewData snapshot and emits named intents; it does not
## resolve services, read Save or make route/economy decisions.
signal intent_emitted(intent_id: String, payload: Dictionary)
var context: ABGPageContext
var view_data: Dictionary = {}
var presentation: Dictionary = {
"safe_area": 24,
"text_scale": 1.0,
}
## Every authored page is laid out in this one coordinate system. The root
## stays 1280x720 and receives one uniform transform for the current viewport;
## children never recompute their own design scale.
const DESIGN_CANVAS_SIZE := Vector2(1280.0, 720.0)
func layout_fixed_design_canvas(canvas: Control) -> void:
if not is_instance_valid(canvas):
return
var available := size
if available.x <= 0.0 or available.y <= 0.0:
return
var uniform_scale := minf(available.x / DESIGN_CANVAS_SIZE.x, available.y / DESIGN_CANVAS_SIZE.y)
canvas.position = (available - DESIGN_CANVAS_SIZE * uniform_scale) * 0.5
canvas.size = DESIGN_CANVAS_SIZE
canvas.scale = Vector2.ONE * uniform_scale
canvas.pivot_offset = Vector2.ZERO
func mount(next_context) -> ABGPageView:
if next_context is ABGPageContext:
context = next_context.duplicate_context()
elif next_context is Dictionary:
context = PageContext.new(next_context)
else:
context = PageContext.new()
return self
func configure_presentation(values: Dictionary = {}) -> ABGPageView:
for key in values.keys():
presentation[String(key)] = values[key]
return self
func render(next_view_data: Dictionary = {}) -> void:
view_data = next_view_data.duplicate(true)
_render_view(view_data)
func emit_intent(intent_id: String, payload: Dictionary = {}) -> void:
intent_emitted.emit(String(intent_id), payload.duplicate(true))
func _render_view(_data: Dictionary) -> void:
# Concrete Views own their node tree and override this method.
pass
func clear_view_children() -> void:
for child in get_children():
child.free()
func page_locale(data: Dictionary = view_data) -> String:
return String(data.get("locale", presentation.get("locale", "zh_CN")))
func localize(zh: String, en: String, data: Dictionary = view_data) -> String:
return zh if page_locale(data) == "zh_CN" else en
func scaled(value: int) -> int:
return int(round(float(value) * float(presentation.get("text_scale", 1.0))))
func make_label(value: String, font_size: int, color: Color) -> Label:
var label := UIFactory.label(value, "body", 1.0, color)
label.add_theme_font_size_override("font_size", font_size)
return label
func make_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 make_company_button(node_name: String, text_value: String, intent_id: String, variant: String, icon: String, intent_payload: Dictionary = {}) -> Button:
var button := UIFactory.command_button(
node_name,
text_value,
variant,
icon,
"angular",
"content" if variant.begins_with("content_") else "company",
Vector2(190, 52),
false,
text_value,
Callable(),
scaled(16)
)
button.pressed.connect(func(): emit_intent(intent_id, intent_payload), CONNECT_DEFERRED)
return button
func set_command_state(button: Button, disabled := false, selected := false) -> Button:
UIFactory.refresh_command_button(button, disabled, selected)
return button
func make_unified_mvp_shell(host: Control, prefix: String, title: String, subtitle: String, state_text: String, compact := false) -> Dictionary:
"""Mount the shared MVP shell grammar without owning page behavior.
The host remains responsible for geometry and intent wiring. The shell only
adds a quiet top rail, a state capsule and a bottom rhythm marker so the
three MVP pages share the same visual syntax over different art masters.
"""
if host == null:
return {}
var shell := Control.new()
shell.name = "%sUnifiedShell" % prefix
shell.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
shell.mouse_filter = Control.MOUSE_FILTER_IGNORE
shell.z_index = 2
host.add_child(shell)
var top := Panel.new()
top.name = "%sShellTopRail" % prefix
top.anchor_right = 1.0
top.offset_bottom = 42.0 if not compact else 4.0
top.mouse_filter = Control.MOUSE_FILTER_IGNORE
if compact:
var compact_top := StyleBoxFlat.new()
compact_top.bg_color = Color(0, 0, 0, 0)
compact_top.border_color = Color(Tokens.COLOR_GOLD, 0.62)
compact_top.set_border_width(SIDE_BOTTOM, Tokens.border_width("hairline"))
top.add_theme_stylebox_override("panel", compact_top)
else:
top.add_theme_stylebox_override("panel", Tokens.surface_style("shell"))
shell.add_child(top)
var heading := UIFactory.label(title, "heading", float(presentation.get("text_scale", 1.0)), Tokens.COLOR_SURFACE_INK)
heading.name = "%sShellTitle" % prefix
heading.anchor_left = 0.03
heading.anchor_top = 0.08
heading.anchor_right = 0.42
heading.anchor_bottom = 0.92
heading.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
top.add_child(heading)
var copy := UIFactory.label(subtitle, "micro", float(presentation.get("text_scale", 1.0)), Tokens.COLOR_GOLD_LIGHT)
copy.name = "%sShellSubtitle" % prefix
copy.anchor_left = 0.42
copy.anchor_top = 0.12
copy.anchor_right = 0.76
copy.anchor_bottom = 0.88
copy.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
copy.mouse_filter = Control.MOUSE_FILTER_IGNORE
top.add_child(copy)
var state := UIFactory.label(state_text, "label", float(presentation.get("text_scale", 1.0)), Tokens.COLOR_GOLD_LIGHT)
state.name = "%sShellState" % prefix
state.anchor_left = 0.76
state.anchor_top = 0.08
state.anchor_right = 0.97
state.anchor_bottom = 0.92
state.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
state.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
state.mouse_filter = Control.MOUSE_FILTER_IGNORE
top.add_child(state)
if compact:
heading.visible = false
copy.visible = false
state.visible = false
var bottom := Panel.new()
bottom.name = "%sShellBottomRail" % prefix
bottom.anchor_top = 1.0
bottom.anchor_right = 1.0
bottom.offset_top = -34.0 if not compact else -2.0
bottom.mouse_filter = Control.MOUSE_FILTER_IGNORE
if compact:
var compact_bottom := StyleBoxFlat.new()
compact_bottom.bg_color = Color(0, 0, 0, 0)
compact_bottom.border_color = Color(Tokens.COLOR_GOLD, 0.42)
compact_bottom.set_border_width(SIDE_TOP, Tokens.border_width("hairline"))
bottom.add_theme_stylebox_override("panel", compact_bottom)
else:
bottom.add_theme_stylebox_override("panel", Tokens.surface_style("context"))
shell.add_child(bottom)
var footer := UIFactory.label(localize("公会记录 · 当前阶段", "GUILD LEDGER · CURRENT STAGE"), "micro", float(presentation.get("text_scale", 1.0)), Tokens.COLOR_MUTED)
footer.name = "%sShellFooter" % prefix
footer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
footer.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
footer.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
footer.mouse_filter = Control.MOUSE_FILTER_IGNORE
footer.visible = not compact
bottom.add_child(footer)
return {"root": shell, "top": top, "bottom": bottom}
func make_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(presentation.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 inset_safe_to_card_face(safe: MarginContainer, edge_x := 0.055, edge_y := 0.15) -> void:
"""Seat page content inside a shared card's rolled edges.
The card's authored roll owns the top/left/right face; content that keeps
only the touch safe-area margin sits on the roll and gets clipped. M67
pages call this right after make_content_page_safe (the M64/M65 pages do the
equivalent inset per page).
"""
if safe == null:
return
var face_x := int(size.x * edge_x) + 14
var face_y := int(size.y * edge_y) + 10
safe.add_theme_constant_override("margin_left", face_x)
safe.add_theme_constant_override("margin_right", face_x)
safe.add_theme_constant_override("margin_top", face_y)
safe.add_theme_constant_override("margin_bottom", int(face_y * 0.55))
func make_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(presentation.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