236 lines
9.9 KiB
GDScript
236 lines
9.9 KiB
GDScript
extends SceneTree
|
|
|
|
const GuildView = preload("res://entry/views/guild_page_view.gd")
|
|
const InkCheck = preload("res://tools/ui_ink_check.gd")
|
|
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]
|
|
# M84-A measured master regions on the 1280x720 canvas (master px x 0.625).
|
|
const MASTER_PATH := "res://assets/backgrounds/m52_q_guild_background.png"
|
|
const PAPER := Rect2(838.0, 144.0, 1172.0 - 838.0, 347.0 - 144.0)
|
|
const PLATE := Rect2(878.0, 139.0, 1185.0 - 878.0, 355.0 - 139.0)
|
|
const CURTAIN := Rect2(812.0, 119.0, 1187.0 - 812.0, 181.0 - 119.0)
|
|
const CONTRAST_MIN := 4.5
|
|
const CONTRAST_LABELS := ["GuildProgressStatus", "GuildCoinStatus", "GuildAnchorCompanyStatus", "GuildMarketReason", "GuildNotice"]
|
|
var master_image: Image
|
|
var m84_failures: Array = []
|
|
var ink_scanned := 0
|
|
var probe_text_scale := 1.0
|
|
var ink_violations: Array = []
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
func _run() -> void:
|
|
var out := OS.get_environment("ABG_EVIDENCE_DIR")
|
|
if out.is_empty():
|
|
push_error("requires ABG_EVIDENCE_DIR")
|
|
quit(1)
|
|
return
|
|
DirAccess.make_dir_recursive_absolute(out)
|
|
var ink_self := InkCheck.self_test(root)
|
|
if ink_self.size() < 3:
|
|
push_error("M62 ink self-test expected at least 3 violations, got %d" % ink_self.size())
|
|
quit(1)
|
|
return
|
|
print("ABG_M62_INK_SELF_TEST_OK violations=%d" % ink_self.size())
|
|
ink_scanned = 0
|
|
ink_violations = []
|
|
for fixture in FIXTURES:
|
|
for locale in LOCALES:
|
|
for text_scale in TEXT_SCALES:
|
|
probe_text_scale = float(text_scale)
|
|
await _capture_fixture(fixture, locale, out)
|
|
var report_path := OS.get_environment("ABG_EVIDENCE_DIR").path_join("ink-violations-%s.json" % _probe_id())
|
|
var file := FileAccess.open(report_path, FileAccess.WRITE)
|
|
if file != null:
|
|
file.store_string(JSON.stringify(ink_violations, " "))
|
|
file.close()
|
|
if ink_violations.is_empty() and m84_failures.is_empty():
|
|
print("ABG_M84_GUILD_BOARD_OK paper=%s curtain_clear=true contrast_min=%.1f" % [str(PAPER), CONTRAST_MIN])
|
|
print("ABG_M53_GUILD_INK_OK scanned=%d violations=0" % ink_scanned)
|
|
else:
|
|
for failure in m84_failures:
|
|
push_error("M84-A guild board: %s" % failure)
|
|
for violation in ink_violations:
|
|
push_error("M53 ink violation %s: text=%s slot=%s font=%s" % [String(violation.get("node", "?")), String(violation.get("text_width", "?")), String(violation.get("slot_width", "?")), String(violation.get("font_size", "?"))])
|
|
print("ABG_M53_GUILD_INK_FAIL scanned=%d violations=%d" % [ink_scanned, ink_violations.size()])
|
|
quit(1)
|
|
return
|
|
print("ABG_M53_GUILD_VISUAL_PROBE_OK states=members locales=%d scans=%d views=static,dynamic,final" % [LOCALES.size(), ink_scanned])
|
|
quit(0)
|
|
|
|
|
|
func _probe_id() -> String:
|
|
return "m53-guild"
|
|
|
|
|
|
func _scan_ink(view: Control, locale: String, label: String) -> void:
|
|
# Legacy-chrome pages (pre-M62) record their ink defects as a baseline for
|
|
# the W5 migration; migrated pages (Market, Readiness once moved) must be
|
|
# strict. The report file is the machine-readable work list.
|
|
var violations := InkCheck.scan(view)
|
|
ink_scanned += 1
|
|
for violation in violations:
|
|
violation["locale"] = locale
|
|
violation["fixture"] = label
|
|
ink_violations.append(violation)
|
|
|
|
func _capture_fixture(fixture: Vector2i, locale: String, out: String) -> void:
|
|
root.size = Vector2(fixture)
|
|
var capture_viewport := SubViewport.new()
|
|
capture_viewport.name = "GuildCaptureViewport"
|
|
capture_viewport.size = fixture
|
|
capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
capture_viewport.transparent_bg = false
|
|
root.add_child(capture_viewport)
|
|
var view := GuildView.new()
|
|
view.name = "GuildVisualProbeView"
|
|
view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
view.configure_presentation({"safe_area": 24, "text_scale": probe_text_scale, "locale": locale})
|
|
capture_viewport.add_child(view)
|
|
view.render({
|
|
"ok": true,
|
|
"locale": locale,
|
|
"company": {
|
|
"owned_count": 2,
|
|
"coin": 38,
|
|
"display_recruits": [
|
|
{"recruit_id": "owned-001", "profession_id": "PF-B01", "rendered_name_snapshot": {"en": "Ari", "zh_CN": "阿里"}, "profession_name": {"en": "Vanguard", "zh_CN": "先锋"}},
|
|
{"recruit_id": "owned-002", "profession_id": "PF-B06", "rendered_name_snapshot": {"en": "Bram", "zh_CN": "布拉姆"}, "profession_name": {"en": "Arcanist", "zh_CN": "奥术师"}},
|
|
],
|
|
},
|
|
"destinations": {},
|
|
"recruits": [],
|
|
"notice": "同名委托需先返回再接受" if locale == "zh_CN" else "Return before accepting the same contract",
|
|
})
|
|
await _settle()
|
|
_scan_ink(view, locale, "%dx%d" % [fixture.x, fixture.y])
|
|
_save(capture_viewport, out, locale, fixture, "final")
|
|
_check_m84(view, locale, out.path_join("guild-%dx%d-%s-final.png" % [fixture.x, fixture.y, locale]))
|
|
var overlay := view.find_child("GuildAnchorRoot", true, false) as Control
|
|
if overlay != null:
|
|
overlay.visible = false
|
|
await _settle()
|
|
_save(capture_viewport, out, locale, fixture, "static")
|
|
if overlay != null:
|
|
overlay.visible = true
|
|
var world := view.find_child("GuildMasterWorldExtension", true, false) as Control
|
|
if world != null:
|
|
world.visible = false
|
|
await _settle()
|
|
_save(capture_viewport, out, locale, fixture, "dynamic")
|
|
view.queue_free()
|
|
capture_viewport.queue_free()
|
|
await process_frame
|
|
|
|
func _master() -> Image:
|
|
if master_image == null:
|
|
master_image = Image.new()
|
|
master_image.load(MASTER_PATH)
|
|
return master_image
|
|
|
|
|
|
func _rect_of(view: Control, node_name: String) -> Rect2:
|
|
var node := view.find_child(node_name, true, false) as Control
|
|
if node == null:
|
|
return Rect2()
|
|
# Global rects are already in 1280x720 canvas pixels; only the master image
|
|
# sample below needs the x1.6 master scale.
|
|
return node.get_global_rect()
|
|
|
|
|
|
func _ink_rect(view: Control, node_name: String) -> Rect2:
|
|
var node := view.find_child(node_name, true, false) as Control
|
|
if node == null:
|
|
return Rect2()
|
|
var label := node as Label
|
|
if label != null:
|
|
return InkCheck.ink_rect(label)
|
|
return _rect_of(view, node_name)
|
|
|
|
|
|
func _luminance(color: Color) -> float:
|
|
var channels := [color.r, color.g, color.b]
|
|
var linear: Array = []
|
|
for value in channels:
|
|
linear.append(value / 12.92 if value <= 0.03928 else pow((value + 0.055) / 1.055, 2.4))
|
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
|
|
|
|
|
func _check_m84(view: Control, locale: String, frame_path: String) -> void:
|
|
var inset := 4.0
|
|
var copy := _rect_of(view, "GuildKioskCopyArea")
|
|
if copy.size.x <= 0.0:
|
|
m84_failures.append("%s copy area missing" % locale)
|
|
return
|
|
var inner := Rect2(PLATE.position + Vector2(inset, inset), PLATE.size - Vector2(inset * 2.0, inset * 2.0))
|
|
if not inner.encloses(copy):
|
|
m84_failures.append("%s copy area %s escapes the plate inset %s" % [locale, str(copy), str(inner)])
|
|
if copy.position.y < PLATE.position.y:
|
|
m84_failures.append("%s copy area top %.1f above the plate top %.1f" % [locale, copy.position.y, PLATE.position.y])
|
|
if CURTAIN.intersects(copy):
|
|
m84_failures.append("%s copy area intersects the red curtain" % locale)
|
|
for child in ["GuildKioskText", "GuildContractReason", "GuildMarketReason", "GuildNotice"]:
|
|
var rect := _rect_of(view, child)
|
|
var ink := _ink_rect(view, child)
|
|
if rect.size.x <= 0.0:
|
|
m84_failures.append("%s %s missing" % [locale, child])
|
|
continue
|
|
if not inner.encloses(rect):
|
|
m84_failures.append("%s %s rect %s escapes the plate inset" % [locale, child, str(rect)])
|
|
if not inner.encloses(ink):
|
|
m84_failures.append("%s %s ink %s escapes the plate inset" % [locale, child, str(ink)])
|
|
if CURTAIN.intersects(rect) or CURTAIN.intersects(ink):
|
|
m84_failures.append("%s %s intersects the red curtain" % [locale, child])
|
|
if not PAPER.encloses(_rect_of(view, "GuildNotice")):
|
|
m84_failures.append("%s GuildNotice is not on the paper" % locale)
|
|
# Contrast is measured on the rendered frame: the top-rail plates only exist at
|
|
# runtime, so sampling the master art would misjudge them. The background probe
|
|
# point sits just below the ink, on the same carrier surface.
|
|
var image := Image.new()
|
|
if image.load(frame_path) != OK:
|
|
m84_failures.append("%s rendered frame could not be read" % locale)
|
|
return
|
|
for node_name in CONTRAST_LABELS:
|
|
var label := view.find_child(node_name, true, false) as Label
|
|
if label == null:
|
|
m84_failures.append("%s %s missing for contrast" % [locale, node_name])
|
|
continue
|
|
if label.text.strip_edges().is_empty():
|
|
continue
|
|
var ink := _ink_rect(view, node_name)
|
|
# worst case for dark ink is the darkest carrier pixel around the glyphs, so
|
|
# the probe samples just outside the four sides instead of inside a stroke
|
|
var candidates := [
|
|
Vector2i(int(ink.get_center().x), clampi(int(ink.position.y) - 2, 0, image.get_height() - 1)),
|
|
Vector2i(int(ink.get_center().x), clampi(int(ink.end.y) + 2, 0, image.get_height() - 1)),
|
|
Vector2i(clampi(int(ink.position.x) - 2, 0, image.get_width() - 1), int(ink.get_center().y)),
|
|
Vector2i(clampi(int(ink.end.x) + 2, 0, image.get_width() - 1), int(ink.get_center().y)),
|
|
]
|
|
var background := image.get_pixel(candidates[0].x, candidates[0].y)
|
|
for point in candidates:
|
|
var pixel := image.get_pixel(point.x, point.y)
|
|
if _luminance(pixel) < _luminance(background):
|
|
background = pixel
|
|
var foreground: Color = label.get_theme_color("font_color")
|
|
var lighter := maxf(_luminance(foreground), _luminance(background))
|
|
var darker := minf(_luminance(foreground), _luminance(background))
|
|
var ratio := (lighter + 0.05) / (darker + 0.05)
|
|
if ratio < CONTRAST_MIN:
|
|
m84_failures.append("%s %s contrast %.2f:1 below %.1f:1" % [locale, node_name, ratio, CONTRAST_MIN])
|
|
|
|
|
|
func _settle() -> void:
|
|
for _i in range(3):
|
|
await process_frame
|
|
|
|
func _save(viewport: SubViewport, out: String, locale: String, fixture: Vector2i, kind: String) -> void:
|
|
var viewport_texture := viewport.get_texture()
|
|
if viewport_texture == null:
|
|
return
|
|
var image := viewport_texture.get_image()
|
|
if image != null:
|
|
image.save_png(out.path_join("guild-%dx%d-%s-%s.png" % [fixture.x, fixture.y, locale, kind]))
|