From 716d66a3d235ddb758e2efae7c9993b154cef6df Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Fri, 18 Sep 2026 07:02:14 +0800 Subject: [PATCH] fix(m83): anchor the party top rail to its approved slots and bind the capacity plate The four authored plates now carry the engine text the approved master and text-slots.json describe: the title, the pressure word, the Company size on the coins plate and the BIND:PARTY_CAPACITY binding on the helmet plate that had no runtime content. Labels are fitted to the authored boxes (Rect2 corners, not sizes), rail type follows the shipped 130% scale inside its slot instead of the page-wide 16px ceiling, and the M66 probe gates all four boxes at both scales. --- runtime/entry/views/party_line_page_view.gd | 97 +++++++++++++++++++- runtime/tools/m66_party_risk_visual_probe.gd | 67 ++++++++++++++ 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/runtime/entry/views/party_line_page_view.gd b/runtime/entry/views/party_line_page_view.gd index dedab2a9..26e6c8d3 100644 --- a/runtime/entry/views/party_line_page_view.gd +++ b/runtime/entry/views/party_line_page_view.gd @@ -15,6 +15,26 @@ const PARTY_LINE_MASTER_PATH := "res://assets/entry/party_line_v001/party-line-m const PageChrome = preload("res://ui/shared/page_chrome.gd") const UITokens = preload("res://foundation/ui_tokens.gd") +## M83: the top rail's four plates are authored in the approved master +## (`docs/design-authority/pages/PTY-001-party-line/approved/v001/master.png`). +## These are the authored text boxes of that master, measured on the approved +## 2048x1152 file and scaled to the 1280x720 design canvas (x0, y0, x1, y1). +## `text-slots.json` only declares the first three; the engine owns the text in +## them and the icon/plate art stays static. +## `Rect2` takes a size, not a second corner: keep the authored corners explicit +## or the label anchors land past the plate. +const RAIL_SLOT_TITLE := Rect2(Vector2(263.8, 31.2), Vector2(415.0 - 263.8, 65.6 - 31.2)) +const RAIL_SLOT_PRESSURE := Rect2(Vector2(511.2, 35.0), Vector2(664.4 - 511.2, 63.8 - 35.0)) +const RAIL_SLOT_COMPANY := Rect2(Vector2(788.8, 31.2), Vector2(933.8 - 788.8, 65.6 - 31.2)) +const RAIL_SLOT_CAPACITY := Rect2(Vector2(1050.6, 31.2), Vector2(1191.9 - 1050.6, 65.6 - 31.2)) +## The authored boxes are the visible plates; the engine label keeps a small +## inset so a glyph never sits on the plate outline. +const RAIL_TEXT_INSET := 4.0 +## UI Restoration Memory: rail type may rise with the accessibility scale, but +## never past the authored slot. The 16px cap this replaces made 130% a no-op. +const RAIL_FONT_CEILING := 30 +const RAIL_FONT_FLOOR := 11 + var design_canvas: Control @@ -76,9 +96,21 @@ func _build_overlay(data: Dictionary) -> void: overlay.mouse_filter = Control.MOUSE_FILTER_PASS design_canvas.add_child(overlay) - _party_label(overlay, "PartyTitle", Rect2(0.205, 0.045, 0.125, 0.05), localize("队列编成", "PARTY LINE", data), 10, PageChrome.INK) - _party_label(overlay, "PartyPressureValue", Rect2(0.415, 0.045, 0.095, 0.05), _party_pressure_short(String(contract_data.get("difficulty", "standard")), data), 10, PageChrome.INK_SOFT) - _party_label(overlay, "PartyCompanyValue", Rect2(0.635, 0.045, 0.105, 0.05), "%d/%d" % [int(company_data.get("owned_count", 0)), int(company_data.get("line_capacity", 0))], 10, PageChrome.INK) + # M83: four authored plates, four engine texts. Plate 3 carries the Company + # size the approved slot names `company_value`; plate 4 carries the + # `BIND:PARTY_CAPACITY` binding, which `docs/03` forbids labelling as the + # owned Company count. Every label is fitted to its authored box instead of a + # hardcoded normalized anchor. + var deployed_count := order_ids.size() + var line_capacity := int(formation_data.get("line_capacity", company_data.get("line_capacity", 0))) + _party_rail_label(overlay, "PartyTitle", RAIL_SLOT_TITLE, + localize("队列编成", "PARTY LINE", data), 10, PageChrome.INK) + _party_rail_label(overlay, "PartyPressureValue", RAIL_SLOT_PRESSURE, + _party_pressure_short(String(contract_data.get("difficulty", "standard")), data), 10, PageChrome.INK_SOFT) + _party_rail_label(overlay, "PartyCompanyValue", RAIL_SLOT_COMPANY, + localize("队伍 %d", "LINE %d", data) % int(company_data.get("owned_count", 0)), 10, PageChrome.INK) + _party_rail_label(overlay, "PartyCompanyCapacityValue", RAIL_SLOT_CAPACITY, + localize("上阵 %d/%d", "LINE %d/%d", data) % [deployed_count, line_capacity], 10, PageChrome.INK) var diagram := PartyLineDiagram.new() diagram.name = "PartyOrderDiagram" @@ -295,6 +327,55 @@ func _party_label(parent: Control, node_name: String, area: Rect2, text_value: S return label +func _party_rail_label(parent: Control, node_name: String, slot: Rect2, text_value: String, design_size: int, color: Color) -> Label: + # M83: one authored box, one engine text. The label rect is the box inset so + # a glyph never touches the plate outline, and the font steps down only when + # the authored type size would not fit the box at the current text scale. + var label := make_label(text_value, _party_rail_font_size(text_value, slot, design_size), color) + label.name = node_name + 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 + label.set_meta("design_font_size", design_size) + label.set_meta("authored_slot", slot) + _party_rail_place(label, slot) + parent.add_child(label) + return label + + +func _party_rail_font_size(text_value: String, slot: Rect2, design_size: int) -> int: + var available := Vector2(slot.size.x - 2.0 * RAIL_TEXT_INSET, slot.size.y - 2.0 * RAIL_TEXT_INSET) + var size := _party_text_size(design_size, false) + while size > RAIL_FONT_FLOOR: + if _party_rail_text_fits(text_value, available, size): + return size + size -= 1 + return RAIL_FONT_FLOOR + + +func _party_rail_text_fits(text_value: String, available: Vector2, font_size: int) -> bool: + var measured := UI_FONT.get_string_size(text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + return measured.x <= available.x and measured.y <= available.y + + +func _party_rail_place(control: Control, slot: Rect2) -> void: + # ``slot`` is an authored pixel rect in the 1280x720 design canvas. + var design := Vector2(1280.0, 720.0) + control.anchor_left = (slot.position.x + RAIL_TEXT_INSET) / design.x + control.anchor_top = (slot.position.y + RAIL_TEXT_INSET) / design.y + control.anchor_right = (slot.end.x - RAIL_TEXT_INSET) / design.x + control.anchor_bottom = (slot.end.y - RAIL_TEXT_INSET) / design.y + control.offset_left = 0.0 + control.offset_top = 0.0 + control.offset_right = 0.0 + control.offset_bottom = 0.0 + control.offset_left = 0.0 + control.offset_top = 0.0 + control.offset_right = 0.0 + control.offset_bottom = 0.0 + + func _party_place(control: Control, area: Rect2) -> void: control.anchor_left = area.position.x control.anchor_top = area.position.y @@ -306,11 +387,17 @@ func _party_place(control: Control, area: Rect2) -> void: control.offset_bottom = 0.0 -func _party_text_size(design_size: int) -> int: +func _party_text_size(design_size: int, clamp_ceiling := true) -> int: var canvas_height := design_canvas.size.y if is_instance_valid(design_canvas) else size.y var canvas_scale := clampf(canvas_height / 360.0, 1.0, 2.0) var text_scale := float(presentation.get("text_scale", 1.0)) - return mini(16, maxi(design_size, int(round(float(design_size) * canvas_scale * text_scale)))) + var scaled := int(round(float(design_size) * canvas_scale * text_scale)) + if not clamp_ceiling: + # M83: the top rail owns an authored slot and fits its own type, so the + # page-wide 16px ceiling (which made the 130% text scale a no-op) does not + # apply to it. The other party labels keep the historical ceiling. + return mini(RAIL_FONT_CEILING, maxi(RAIL_FONT_FLOOR, scaled)) + return mini(16, maxi(design_size, scaled)) func _party_pressure_short(difficulty: String, data: Dictionary) -> String: diff --git a/runtime/tools/m66_party_risk_visual_probe.gd b/runtime/tools/m66_party_risk_visual_probe.gd index dc357862..d74258bc 100644 --- a/runtime/tools/m66_party_risk_visual_probe.gd +++ b/runtime/tools/m66_party_risk_visual_probe.gd @@ -13,6 +13,18 @@ const FIXTURES := [Vector2i(1280, 720)] const LOCALES := ["zh_CN", "en"] # Accessibility pass: 1.0 plus the shipped 130% text scale (M73). const TEXT_SCALES := [1.0, 1.3] +## M83: the Party Line top rail's four authored text boxes, measured on the +## approved master (`docs/design-authority/pages/PTY-001-party-line/ +## approved/v001/master.png`) and scaled to the 1280x720 design canvas. +const RAIL_SLOTS := [ + {"node": "PartyTitle", "owner": "party_title", "rect": Rect2(Vector2(263.8, 31.2), Vector2(415.0 - 263.8, 65.6 - 31.2))}, + {"node": "PartyPressureValue", "owner": "pressure_value", "rect": Rect2(Vector2(511.2, 35.0), Vector2(664.4 - 511.2, 63.8 - 35.0))}, + {"node": "PartyCompanyValue", "owner": "company_value", "rect": Rect2(Vector2(788.8, 31.2), Vector2(933.8 - 788.8, 65.6 - 31.2))}, + {"node": "PartyCompanyCapacityValue", "owner": "party_capacity", "rect": Rect2(Vector2(1050.6, 31.2), Vector2(1191.9 - 1050.6, 65.6 - 31.2))}, +] +# A rail label whose type is identical at 130% still sits under the page-wide +# 16px ceiling, which makes the accessibility scale a no-op on that page. +var rail_font_by_scale := {} var ink_scanned := 0 var probe_text_scale := 1.0 var ink_violations: Array = [] @@ -60,6 +72,8 @@ func _run() -> void: print("ABG_M66_PARTY_RISK_INK_FAIL scanned=%d violations=%d" % [ink_scanned, ink_violations.size()]) quit(1) return + print("ABG_M83_PARTY_TOP_RAIL_OK slots=%d node=PartyTitle,PartyPressureValue,PartyCompanyValue,PartyCompanyCapacityValue scales=1.0,1.3 fonts=%s" % [ + RAIL_SLOTS.size(), str(rail_font_by_scale)]) print("ABG_M66_PARTY_RISK_VISUAL_PROBE_OK pages=party_line,risk_board,risk_commitment locales=%d scans=%d views=static,dynamic,final" % [LOCALES.size(), ink_scanned]) quit(0) @@ -88,6 +102,15 @@ func _capture(page: Dictionary, fixture: Vector2i, locale: String, out: String) violation["fixture"] = "%dx%d" % [fixture.x, fixture.y] violation["mode"] = mode ink_violations.append(violation) + if page_name == "party_line" and mode == "final": + for problem in _rail_problems(view, locale): + ink_violations.append({ + "kind": "rail_slot", "page": page_name, "locale": locale, + "fixture": "%dx%d" % [fixture.x, fixture.y], "mode": mode, + "node": String(problem.get("node", "rail")), + "path": "party_line/rail", + "detail": String(problem.get("detail", "")), + }) var image := viewport.get_texture().get_image() if image != null: image.save_png(out.path_join("%s-%dx%d-%s-%s.png" % [page_name, fixture.x, fixture.y, locale, mode])) @@ -96,6 +119,50 @@ func _capture(page: Dictionary, fixture: Vector2i, locale: String, out: String) await process_frame +func _rail_problems(view: Control, locale: String) -> Array: + # M83: every authored top-rail box must carry a non-empty, contained engine + # text at both shipped text scales, and the 130% scale must actually change + # the rail type (the page-wide 16px ceiling used to make it a no-op). + var problems: Array = [] + var fonts: Array = [] + for slot in RAIL_SLOTS: + var node_name := String(slot.get("node", "")) + var owner := String(slot.get("owner", "")) + var box := Rect2(slot.get("rect", Rect2())) + var label := view.find_child(node_name, true, false) as Label + if label == null: + problems.append({"node": node_name, "detail": "rail slot %s has no runtime text node" % owner}) + continue + if label.text.strip_edges().is_empty(): + problems.append({"node": node_name, "detail": "rail slot %s is empty" % owner}) + continue + var rect := label.get_global_rect() + if not _rect_inside(rect, box): + problems.append({"node": node_name, "detail": "label rect %s leaves the authored box %s" % [str(rect), str(box)]}) + var ink: Rect2 = InkCheck.ink_rect(label) + if not _rect_inside(ink, box): + problems.append({"node": node_name, "detail": "ink %s leaves the authored box %s" % [str(ink), str(box)]}) + fonts.append(label.get_theme_font_size("font_size")) + rail_font_by_scale["%s@%.1f" % [locale, probe_text_scale]] = fonts + if OS.get_environment("ABG_M66_RAIL_TRACE") == "1": + print("ABG_M66_RAIL_TRACE locale=%s scale=%.1f fonts=%s problems=%d" % [locale, probe_text_scale, str(fonts), problems.size()]) + var at_100: Array = Array(rail_font_by_scale.get("%s@1.0" % locale, [])) + if is_equal_approx(probe_text_scale, 1.3) and at_100.size() == fonts.size() and not at_100.is_empty(): + var raised := false + for index in range(fonts.size()): + if int(fonts[index]) > int(at_100[index]): + raised = true + if not raised: + problems.append({"node": "PartyRailScale", + "detail": "rail type is identical at 100%% and 130%% (%s)" % str(fonts)}) + return problems + + +func _rect_inside(inner: Rect2, outer: Rect2) -> bool: + return inner.position.x >= outer.position.x - 0.5 and inner.position.y >= outer.position.y - 0.5 \ + and inner.end.x <= outer.end.x + 0.5 and inner.end.y <= outer.end.y + 0.5 + + func _apply_mode(view: Control, page_name: String, mode: String) -> void: # static-only: the authored master with no runtime content. # dynamic-only: runtime content on a neutral field (slot overflow, contrast,