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