diff --git a/runtime/entry/views/guild_page_view.gd b/runtime/entry/views/guild_page_view.gd index afafaf61..b0dc9219 100644 --- a/runtime/entry/views/guild_page_view.gd +++ b/runtime/entry/views/guild_page_view.gd @@ -15,7 +15,6 @@ const M52_Q_GUILD_BACKGROUND = preload("res://assets/backgrounds/m52_q_guild_bac const M52_GUILD_RESOURCE_STATUS = preload("res://ui/m52/guild_resource_status.gd") const M52_GUILD_CONTRACT_BOARD = preload("res://ui/m52/guild_contract_board.gd") const M52_GUILD_MEMBER_CARD = preload("res://ui/m52/guild_member_card.gd") -const M52_GUILD_NAVIGATION = preload("res://ui/m52/guild_navigation.gd") const M52_GUILD_HEADER = preload("res://ui/m52/guild_header.gd") const GUILD_DESK_CONTRACT_BOARD = preload("res://assets/entry/guild_desk_v001/contract-board.png") const GUILD_DESK_TOP_LEFT = preload("res://assets/entry/guild_desk_v001/top_rail/left.png") @@ -258,16 +257,6 @@ func _build_overlay(data: Dictionary) -> void: _add_nav_button(overlay, "GuildEquipment", "GuildEquipmentCommandLabel", localize("装备", "Gear", page_data), "equipment", "equipment", 1, not bool(equipment_state.get("enabled", false)), page_data) _add_nav_button(overlay, "GuildParty", "GuildPartyCommandLabel", localize("队列", "Party", page_data), "party", "party", 2, not bool(party_state.get("enabled", false)), page_data) _add_nav_button(overlay, "GuildRisk", "GuildRouteStatusLabel", localize("风险", "Risk", page_data), "risk", "risk", 3, not bool(risk_state.get("enabled", false)), page_data) - var navigation := M52_GUILD_NAVIGATION.new().configure([ - localize("成员", "Roster", page_data), localize("装备", "Gear", page_data), - localize("队列", "Party", page_data), localize("风险", "Risk", page_data) - ]) - navigation.name = "GuildNavigation" - _set_normalized_rect(navigation, Rect2(0.035, 0.855, 0.48, 0.12)) - navigation.z_index = 20 - navigation.mouse_filter = Control.MOUSE_FILTER_IGNORE - overlay.add_child(navigation) - var contract_state: Dictionary = Dictionary(destinations.get("contract", {})) var market_state: Dictionary = Dictionary(destinations.get("market", {})) var contract_enabled := bool(contract_state.get("enabled", false)) diff --git a/runtime/entry/views/market_page_view.gd b/runtime/entry/views/market_page_view.gd index e6b5f143..4e26d6af 100644 --- a/runtime/entry/views/market_page_view.gd +++ b/runtime/entry/views/market_page_view.gd @@ -9,7 +9,6 @@ extends "res://presentation/page_view.gd" const InitialCompanyArt = preload("res://entry/initial_company_art.gd") const MARKET_STATIC_MASTER = preload("res://assets/entry/shp_001_market/market-static-master.png") -const M50_MARKET_BACKGROUND = preload("res://assets/ui_m50/market_background.png") const M52_Q_MARKET_BACKGROUND = preload("res://assets/backgrounds/m52_q_market_world_only.png") const MarketChrome = preload("res://ui/shared/market_chrome.gd") const MarketSlots = preload("res://ui/shared/m52_market_slots.gd") @@ -20,7 +19,6 @@ const M52MarketHeader = preload("res://ui/m52/market_header.gd") const M52ResourceStatusGroup = preload("res://ui/m52/resource_status_group.gd") const M52FooterNav = preload("res://ui/m52/footer_nav.gd") -const USE_M50_RESPONSIVE_SLICE := true var design_canvas: Control @@ -33,37 +31,7 @@ func _notification(what: int) -> void: func _render_view(data: Dictionary) -> void: clear_view_children() design_canvas = null - if USE_M50_RESPONSIVE_SLICE: - _render_m52_market_restore(data) - return - - var matte := ColorRect.new() - matte.name = "MarketMasterMatte" - matte.color = Color("#17252c") - matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - matte.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(matte) - - design_canvas = Control.new() - design_canvas.name = "MarketMasterCanvas" - design_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(design_canvas) - _layout_design_canvas() - - var master := TextureRect.new() - master.name = "MarketStaticMaster" - master.texture = MARKET_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 - design_canvas.add_child(master) - - if not bool(data.get("ok", false)): - _render_invalid(data) - return - _build_overlay(data) + _render_m52_market_restore(data) func _layout_design_canvas() -> void: @@ -500,269 +468,6 @@ func _market_command_button(node_name: String, area: Rect2, intent_id: String, p return button -## M50 responsive slice ----------------------------------------------------- -## This path is intentionally self-contained so the old SHP-001 composition -## can remain available while the owner evaluates the new component grammar. -## It uses a full-bleed world image plus stretch-safe generated elements; no -## runtime text is baked into those images. - -func _render_m50_responsive_slice(data: Dictionary) -> void: - var background := TextureRect.new() - background.name = "M50MarketWorldBackground" - background.texture = M52_Q_MARKET_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 matte := ColorRect.new() - matte.name = "MarketMasterMatte" - matte.color = Color(0.08, 0.05, 0.03, 0.05) - matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - matte.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(matte) - - var veil := ColorRect.new() - veil.name = "MarketLegacyVeil" - # The approved Q background already carries its own painted value structure; - # keep only a light readability veil so the scene does not become a dark - # dashboard beneath the runtime controls. - veil.color = Color(0.02, 0.035, 0.05, 0.08) - veil.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - veil.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(veil) - # Stable legacy identities remain present for route/capture contracts while - # the responsive slice owns the visible composition. - var legacy_canvas := Control.new() - legacy_canvas.name = "MarketMasterCanvas" - legacy_canvas.visible = false - add_child(legacy_canvas) - var legacy_available := size - if legacy_available.x > 0.0 and legacy_available.y > 0.0: - var legacy_canvas_size := Vector2(legacy_available.y * (16.0 / 9.0), legacy_available.y) if legacy_available.x / legacy_available.y >= (16.0 / 9.0) else Vector2(legacy_available.x, legacy_available.x / (16.0 / 9.0)) - legacy_canvas.position = (legacy_available - legacy_canvas_size) * 0.5 - legacy_canvas.size = legacy_canvas_size - var legacy_master := TextureRect.new() - legacy_master.name = "MarketStaticMaster" - legacy_master.texture = MARKET_STATIC_MASTER - legacy_master.visible = false - legacy_canvas.add_child(legacy_master) - var legacy_layout := Control.new() - legacy_layout.name = "MarketLayout" - legacy_layout.visible = false - legacy_canvas.add_child(legacy_layout) - if not bool(data.get("ok", false)): - var invalid := UIFactory.label(String(data.get("notice", data.get("code", _tr("市场不可用", "MARKET UNAVAILABLE")))), "body", 1.0, Tokens.COLOR_IVORY) - invalid.name = "MarketInvalidOverlay" - invalid.set_anchors_and_offsets_preset(Control.PRESET_CENTER) - invalid.custom_minimum_size = Vector2(280, 80) - invalid.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - invalid.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - invalid.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - add_child(invalid) - return - - var safe := MarginContainer.new() - safe.name = "M50MarketSafeArea" - safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - var margin := 18 if size.x >= 900.0 else 12 - safe.add_theme_constant_override("margin_left", margin) - safe.add_theme_constant_override("margin_right", margin) - safe.add_theme_constant_override("margin_top", 12) - safe.add_theme_constant_override("margin_bottom", 12) - add_child(safe) - - var page := VBoxContainer.new() - page.name = "M50MarketPage" - page.add_theme_constant_override("separation", 8) - page.size_flags_vertical = Control.SIZE_EXPAND_FILL - safe.add_child(page) - - var top := HBoxContainer.new() - top.name = "M50MarketTopBar" - top.custom_minimum_size.y = 58 - top.add_theme_constant_override("separation", 10) - var top_surface := Panel.new() - top_surface.name = "MarketTopBarSurface" - top_surface.custom_minimum_size.y = 58 - top_surface.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var top_style := StyleBoxFlat.new() - top_style.bg_color = Color(0.08, 0.13, 0.15, 0.78) - top_style.border_color = Color(Tokens.COLOR_GOLD_LIGHT, 0.62) - top_style.set_border_width_all(1) - top_style.set_corner_radius_all(10) - top_surface.add_theme_stylebox_override("panel", top_style) - page.add_child(top_surface) - top.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - top_surface.add_child(top) - var title_plaque := PanelContainer.new() - title_plaque.name = "MarketTitlePlaque" - title_plaque.custom_minimum_size = Vector2(210, 52) - title_plaque.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - title_plaque.add_theme_stylebox_override("panel", _flat_style(Color(0.94, 0.87, 0.70, 0.97), Color(0.47, 0.31, 0.18, 0.95), 2, 10)) - var title_stack := VBoxContainer.new() - title_stack.alignment = BoxContainer.ALIGNMENT_CENTER - title_stack.add_theme_constant_override("separation", 0) - var title := UIFactory.label(_tr("市场", "MARKET", data), "heading", 1.0, Tokens.COLOR_INK) - title.name = "M50MarketTitle" - title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title.add_theme_color_override("font_outline_color", Color(0.80, 0.65, 0.40, 0.45)) - title.add_theme_constant_override("outline_size", 1) - title_stack.add_child(title) - var stage := UIFactory.label(_tr("准备出发", "PREPARE TO DEPART", data), "micro", 1.0, Color("#7b5228")) - stage.name = "M50MarketStage" - stage.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title_stack.add_child(stage) - title_plaque.add_child(title_stack) - top.add_child(title_plaque) - var company_facts: Dictionary = Dictionary(data.get("company", {})) - var company_status := _m50_resource_chip("MarketCompanyStatus", "company", "%s %d/%d" % [_tr("队伍", "LINE", data), int(company_facts.get("owned_count", 0)), int(company_facts.get("line_capacity", 0))]) - top.add_child(company_status) - var coin := _m50_resource_chip("MarketCoinStatus", "coin", "%s %d" % [_tr("金币", "COIN", data), int(company_facts.get("coin", 0))]) - top.add_child(coin) - var settings := UIFactory.command_button("MarketSettings", "", "icon", "settings", "flat", "content", Vector2(52, 48)) - settings.tooltip_text = _tr("打开设置", "Open settings", data) - settings.pressed.connect(func(): emit_intent("settings", {}), CONNECT_DEFERRED) - top.add_child(settings) - - var body := HBoxContainer.new() - body.name = "M50MarketBody" - body.add_theme_constant_override("separation", 8 if size.x < 820.0 else 14) - body.size_flags_vertical = Control.SIZE_EXPAND_FILL - page.add_child(body) - - var left := VBoxContainer.new() - left.name = "M50MarketOffersColumn" - left.size_flags_horizontal = Control.SIZE_EXPAND_FILL - left.size_flags_vertical = Control.SIZE_EXPAND_FILL - left.add_theme_constant_override("separation", 8) - body.add_child(left) - var offer_heading := UIFactory.label(_tr("可用招募", "AVAILABLE RECRUITS", data), "label", 1.0, Tokens.COLOR_IVORY) - offer_heading.name = "M50MarketOffersHeading" - var offer_heading_surface := PanelContainer.new() - offer_heading_surface.name = "MarketOffersHeadingSurface" - offer_heading_surface.custom_minimum_size.y = 32 - var heading_style := StyleBoxFlat.new() - heading_style.bg_color = Color(0.08, 0.13, 0.15, 0.68) - heading_style.border_color = Color(Tokens.COLOR_GOLD_LIGHT, 0.48) - heading_style.set_border_width_all(1) - heading_style.set_corner_radius_all(8) - offer_heading_surface.add_theme_stylebox_override("panel", heading_style) - offer_heading_surface.add_child(offer_heading) - left.add_child(offer_heading_surface) - var offer_list := HBoxContainer.new() - offer_list.name = "MarketOfferList" - offer_list.add_theme_constant_override("separation", 10) - offer_list.size_flags_vertical = Control.SIZE_EXPAND_FILL - left.add_child(offer_list) - - var offers: Dictionary = Dictionary(data.get("offers", {})) - var recruit_offers: Array = Array(offers.get("recruit", [])) - var selected_offer_id := String(data.get("selected_offer_id", "")) - var selected_kind := String(data.get("selected_kind", "")) - for index in range(mini(3, recruit_offers.size())): - offer_list.add_child(_m50_recruit_card(index, recruit_offers[index], selected_kind, selected_offer_id, data)) - if String(data.get("category", "recruit")) == "equipment": - # Keep the legacy four-socket contract while the first slice focuses on - # recruit cards; equipment presentation migrates after owner review. - while offer_list.get_child_count() < 4: - var compatibility_socket := Control.new() - compatibility_socket.name = "MarketEquipmentSocket%d" % offer_list.get_child_count() - compatibility_socket.visible = false - offer_list.add_child(compatibility_socket) - if recruit_offers.is_empty(): - var empty := UIFactory.label(_tr("暂无可用招募", "NO RECRUITS AVAILABLE"), "body", 1.0, Tokens.COLOR_IVORY) - empty.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - offer_list.add_child(empty) - - var selected: Dictionary = Dictionary(data.get("selected_offer", {})) - var board := Panel.new() - board.name = "MarketContextSurface" - board.add_theme_stylebox_override("panel", _flat_style(Color(0.15, 0.25, 0.25, 0.94), Color(Tokens.COLOR_GOLD_LIGHT, 0.78), 2, 10)) - board.custom_minimum_size = Vector2(260 if size.x < 820.0 else 286, 0) - board.size_flags_horizontal = Control.SIZE_EXPAND - board.size_flags_vertical = Control.SIZE_EXPAND_FILL - body.add_child(board) - var board_margin := MarginContainer.new() - board_margin.name = "M50CommissionBoardContent" - board_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - var board_side_margin := 18 if size.x < 820.0 else 28 - board_margin.add_theme_constant_override("margin_left", board_side_margin) - board_margin.add_theme_constant_override("margin_right", board_side_margin) - board_margin.add_theme_constant_override("margin_top", 24 if size.x < 820.0 else 32) - board_margin.add_theme_constant_override("margin_bottom", 20 if size.x < 820.0 else 28) - board.add_child(board_margin) - var board_content := VBoxContainer.new() - board_content.add_theme_constant_override("separation", 7) - board_margin.add_child(board_content) - var board_title := UIFactory.label(_tr("候选人详情", "CANDIDATE DETAILS"), "heading", 1.0, Tokens.COLOR_IVORY) - board_title.name = "M50CommissionBoardTitle" - board_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - if size.x < 820.0: - board_title.add_theme_font_size_override("font_size", 18) - board_content.add_child(board_title) - var instruction := UIFactory.label(_tr("职业、特质与招募价格", "ROLE, TRAIT AND RECRUITMENT COST"), "micro", 1.0, Color("#ead49b")) - instruction.name = "M50CommissionInstruction" - instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - board_content.add_child(instruction) - var divider := HSeparator.new() - divider.name = "M50CommissionDivider" - board_content.add_child(divider) - var detail_surface := PanelContainer.new() - detail_surface.name = "MarketCandidateFactSurface" - detail_surface.size_flags_vertical = Control.SIZE_EXPAND_FILL - detail_surface.add_theme_stylebox_override("panel", _flat_style(Color(0.94, 0.86, 0.68, 0.94), Color(0.47, 0.31, 0.18, 0.70), 1, 8)) - var detail := UIFactory.label("", "body", 1.0, Tokens.COLOR_INK) - detail.name = "M50CommissionDetail" - detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - detail.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - detail_surface.add_child(detail) - board_content.add_child(detail_surface) - var review := UIFactory.command_button("MarketReviewRecruit", _tr("查看详情", "REVIEW DETAILS"), "primary", "inspect", "angular", "company", Vector2(0, 52)) - review.size_flags_horizontal = Control.SIZE_EXPAND_FILL - review.disabled = selected_offer_id.is_empty() or selected_kind != "recruit" - UIFactory.refresh_command_button(review, review.disabled, false) - review.pressed.connect(func(): emit_intent("recruit_review", {}), CONNECT_DEFERRED) - board_content.add_child(review) - if selected_offer_id.is_empty(): - detail.text = _tr("从左侧选择一名候选人。\n这里会显示职业、特质和招募价格。", "Select a candidate on the left.\nProfession, trait and recruitment cost will appear here.") - else: - var p := _offer_presentation(selected) - detail.text = "%s\n%s\n%s" % [String(p.get("title", "")), String(p.get("short_detail", "")), String(p.get("state_text", ""))] - - var footer := HBoxContainer.new() - footer.name = "M50MarketFooter" - footer.add_theme_constant_override("separation", 10) - footer.custom_minimum_size.y = 52 - page.add_child(footer) - var back := UIFactory.command_button("MarketBackGuild", _tr("返回公会", "BACK TO GUILD"), "utility", "back", "angular", "content", Vector2(150, 48)) - back.pressed.connect(func(): emit_intent("back", {}), CONNECT_DEFERRED) - footer.add_child(back) - var footer_hint := UIFactory.label(_tr("准备 → 编队 → 风险 → 出发", "PREPARE → FORM → RISK → DEPART"), "micro", 1.0, Tokens.COLOR_GOLD_LIGHT) - footer_hint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - footer_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL - footer.add_child(footer_hint) - var return_command_facts: Dictionary = Dictionary(Dictionary(data.get("commands", {})).get("return_contract", {})) - var return_enabled := bool(return_command_facts.get("enabled", false)) - var return_contract := UIFactory.command_button("MarketReturnContract", _tr("返回委托", "RETURN"), "danger", "risk", "angular", "company", Vector2(148, 48)) - return_contract.disabled = not return_enabled - UIFactory.refresh_command_button(return_contract, return_contract.disabled, false) - return_contract.set_meta("market_command_role", "contract_return") - return_contract.tooltip_text = _return_contract_tooltip(String(return_command_facts.get("reason", "market_required")), data) - return_contract.pressed.connect(func(): emit_intent("return_contract", {}), CONNECT_DEFERRED) - footer.add_child(return_contract) - - # Category commands stay available to keyboard/touch contracts but remain - # secondary to the recruit decision in this first responsive slice. - for category in ["equipment", "battle"]: - var category_button := UIFactory.command_button("MarketCategory%s" % category.capitalize(), "", "secondary", "generic", "flat", "content") - category_button.visible = false - category_button.pressed.connect(func(): emit_intent("category", {"category": category}), CONNECT_DEFERRED) - add_child(category_button) - - ## M52 design restoration -------------------------------------------------- ## The page follows the approved board's physical grammar: a market rail, ## hanging parchment recruit cards, a warm adventurer ledger and matching wood @@ -823,6 +528,10 @@ func _render_m52_market_restore(data: Dictionary) -> void: page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) page.mouse_filter = Control.MOUSE_FILTER_PASS canvas.add_child(page) + # Slot adapters choose the authored compact/desktop grammar from the + # fitted viewport, never from the fixed root's 1280 design units. + var fitted_width := minf(size.x, size.y * (16.0 / 9.0)) + var fitted_layout_size := Vector2(fitted_width, fitted_width * (9.0 / 16.0)) # Header owns the plaque and its local text safety slots. Market only # positions this module on the shared 16:9 design canvas. @@ -879,7 +588,7 @@ func _render_m52_market_restore(data: Dictionary) -> void: var market_category := String(data.get("category", "recruit")) var selected_offer_id := String(data.get("selected_offer_id", "")) var selected_kind := String(data.get("selected_kind", "")) - var card_areas: Array[Rect2] = M52MarketSlotAdapter.card_areas(design_canvas.size) + var card_areas: Array[Rect2] = M52MarketSlotAdapter.card_areas(fitted_layout_size) if market_category == "recruit": for index in range(mini(3, recruit_offers.size())): offer_list.add_child(_m52_recruit_card_component(index, recruit_offers[index], card_areas[index], selected_kind, selected_offer_id, data)) @@ -922,7 +631,7 @@ func _render_m52_market_restore(data: Dictionary) -> void: "action_disabled": not has_selection, }) ledger.name = "MarketContextSurface" - _market_place(ledger, M52MarketSlotAdapter.ledger_area(design_canvas.size)) + _market_place(ledger, M52MarketSlotAdapter.ledger_area(fitted_layout_size)) ledger.z_index = 12 ledger.action_requested.connect(func(): emit_intent("recruit_review", {}), CONNECT_DEFERRED) page.add_child(ledger) diff --git a/runtime/ui/m52/candidate_ledger.gd b/runtime/ui/m52/candidate_ledger.gd index 6b982030..fb80c664 100644 --- a/runtime/ui/m52/candidate_ledger.gd +++ b/runtime/ui/m52/candidate_ledger.gd @@ -128,7 +128,11 @@ 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. - var compact := size.x < 300.0 or size.y < 380.0 + # `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 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 diff --git a/runtime/ui/m52/market_slot_adapter.gd b/runtime/ui/m52/market_slot_adapter.gd index 6f55c1d7..c9a132e9 100644 --- a/runtime/ui/m52/market_slot_adapter.gd +++ b/runtime/ui/m52/market_slot_adapter.gd @@ -25,7 +25,10 @@ static func ledger_area(canvas_size: Vector2) -> Rect2: # At 760x360 and 844x390 the ledger gets enough height for a 48px # action dock while remaining clear of the card row horizontally. # Start below the resource rail and end above the footer dock. - return Rect2(0.585, 0.150, 0.375, 0.675) + # Keep the complete ledger (including its minimum-height action dock) + # above the shared footer. The fixed design root makes the local ledger + # taller than its play-size, so reserve a clear lower gap here. + return Rect2(0.585, 0.080, 0.375, 0.700) return Rect2(0.655, 0.205, 0.310, 0.635) static func is_compact(canvas_size: Vector2) -> bool: