diff --git a/design/qa/m3-iter9-t8-rulings-2026-09-30.md b/design/qa/m3-iter9-t8-rulings-2026-09-30.md new file mode 100644 index 00000000..39312f22 --- /dev/null +++ b/design/qa/m3-iter9-t8-rulings-2026-09-30.md @@ -0,0 +1,38 @@ +# 迭代 9 卡⑤ 随闸三裁决呈报单(2026-09-30) + +> 呈报人=迭代 9 执行会话;裁决权=用户。三案全部列全(含已淘汰+依据),每案附数据/代价/联动。**T9 分辨率落码与 T10 黄金图基准卡在裁决一/三上**——裁决前两卡挂起(其余卡不阻塞)。 +> 实测材料:`artwork/qa-screens-20260930/`(1280×800 四屏+shot_meta.json)/ `artwork/qa-screens-1920-20260930/`(1920×1080 四屏)。 + +## 裁决一:1920×1080 档缩放策略 + +**现状实测(1920 窗口实跑)**:当前无统一策略的**原生重排**——B 区高 252 与 C 区宽 240 纹丝不动、棋盘格公式重算(板区 1036→1178 宽)、**字号全部不放大**(像素字在 1920 屏视觉变小 ~33%)、舞台宽 1422 非全宽(布局只部分响应)。三区行为不一致=当前态的实测事实。 + +| 案 | 内容 | 代价 | 联动 | +|---|---|---|---| +| **甲·全局 1.5× 等比**(推荐) | Godot stretch=canvas_items:1920 窗口整屏放大 1.5×,构图与 1280 设计完全一致 | 像素 1.5 非整数缩放→甲类件(数字/瓦片/图标)轻微宽窄不均(3:2 交替纹);§8.2 分级下甲类「严格整数」条款需加 1920 档例外注记 | 一处 stretch 配置+§8.2 注记;格子公式/布局零改;HUD「两档定值 36/45px」条款按 1.5× 折算修订 | +| 乙·1920 档独立 2× 基准 | 1920 档按 2× 像素基准重排 UI(字号/间距/资产出 2× 档) | 两套 UI 尺寸面全维护;字体现无 2× 资产档(升档批才有) | 工作量约一个独立迭代;像素绝对均匀 | +| 丙·维持现状(原生重排) | 零改动 | 字号偏小+三区行为不一致(已实测)为常态 | 零 | + +**推荐:甲**——成本近零、构图一致;1.5 非整数不均=像素游戏 shipped 常态,实机目检可感不适再升级乙。 + +## 裁决二:动画帧数规格——事实核对(非新裁决) + +核对结果:**帧数已随样张 v2 冻结**(圣经 §8.4「帧数规格(样张 v2 冻结 2026-09-28)」:待机 4 帧/攻击·技能准备 6 帧单次/受击 2 帧/死亡=灰化+碎裂退场;切片实装=程序化 tween 已服役)。06「美术资产规格两项」之②系**过期登记**(登记早于样张 v2 冻结批)。 + +**待你表态仅一项**:追认「帧数已冻结、06 该半条关闭」即可(无需再裁数字)。 + +## 裁决三:资产升档与否(48×72→64×96 等) + +**数据**:主角占屏高约 9%(Moonlighter 约 15%,v3.0 病根 6);本轮 B 区舞台(暗背景+落影+顶光柱)已显著改善角色存在感;fixer 像素化管线现成(八敌+主角三态重产成本可控);A4 尺寸基线已按现行实态冻结(立绘 46×48/小人源 32×32——升档=显式解冻重校)。 + +| 案 | 内容 | 代价 | 联动 | +|---|---|---|---| +| 甲·本迭代升档重产 | 主角三态+八敌出 64×96/64/96/128 档并换代 | +0.5–1 工作日+新稿相位/断线手修轮次风险 | T10 黄金图基准延后到升档后建;A4 断言解冻重校;B 区站桩槽 2× 放大参数随动 | +| **乙·Alpha 后再议**(推荐) | 本迭代不升档,先收 Alpha 真人读数——B 区舞台已改善存在感,升档需求强度待真人验证 | 主角存在感维持现态 | T10 黄金图基准本迭代内可建(不被升档作废);升档批留 M-4 候选 | +| 丙·不升(维持 46×48) | 永久维持现行档 | 存在感议题永久关闭 | A4 冻结永久有效 | + +**推荐:乙**——视觉大变更应在 Alpha 验收读数之后再动;本轮舞台落影已吃掉病根 6 的一半。 + +## 附:卡⑤整屏样张闸机检读数(2026-09-30 实测) + +四文字框 WCAG 5.79–13.14(≥4.5 ✓)/明度三档带 204>79>55 ✓/纹理幅度 0.0–12.9(≤18 ✓)/四屏骨架 std 18.6–64.9 ✓;焦点预算=目检项归段闸人闸。工具=`tools/shot_checker.py`(几何源=shot_probe 实测 meta,档位自适应)。 diff --git a/game/smoke/shot_probe.gd b/game/smoke/shot_probe.gd index 42b80f3f..5eed7b13 100644 --- a/game/smoke/shot_probe.gd +++ b/game/smoke/shot_probe.gd @@ -12,6 +12,7 @@ extends Node var main var out_dir := "" var _problems: Array = [] +var _meta := {} # 控件实测几何(全局 rect——shot_checker 文字框/明度带取样坐标源,禁硬编码像素) func _ready() -> void: @@ -34,6 +35,9 @@ func _ready() -> void: main.settings_store = load("res://presentation/settings_store.gd").new("user://smoke_shot_probe_settings.cfg") await get_tree().process_frame # ① 菜单(启动态) + _meta["menu_sub"] = _find_label_rect(main._start_overlay, "改造批") + _meta["menu_help"] = _find_label_rect(main._start_overlay, "玩法:") + _meta["menu_title"] = _find_label_rect(main._start_overlay, "Dungeon Sweeper") await _snap("menu") # ② 营地 var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地") @@ -62,8 +66,18 @@ func _ready() -> void: for i in 10: await get_tree().process_frame await _snap("battle") + # 战斗屏文字框/明度带锚点(全局 rect——checker 取样坐标源) + _meta["battle_log_hint"] = _find_label_rect(main.stage, "结算明细") + _meta["hero_panel"] = _node_rect(_find_by_script(main, "hero_info_panel_view")) + _meta["stage_view"] = _node_rect(main.stage) + _meta["board_zone"] = _node_rect(main.board_zone) else: _problems.append("关卡画面未出现(lvbtn 路径)") + # ── 几何元数据落盘(shot_checker 取样坐标源) ── + var mf := FileAccess.open(out_dir.path_join("shot_meta.json"), FileAccess.WRITE) + if mf != null: + mf.store_string(JSON.stringify(_meta)) + mf.close() # ── 最小自检:四件存在且非零字节 ── for s in ["menu", "camp", "level", "battle"]: var p := out_dir.path_join("shot_%s.png" % s) @@ -98,3 +112,37 @@ func _find_button_by_prefix(root: Node, prefix: String) -> Button: if r != null: return r return null + + +# 控件实测几何采集(checker 断言坐标源——禁硬编码像素,分辨率档位自适应) +func _find_label_rect(root: Node, text_fragment: String) -> Dictionary: + if root == null: + return {} + if root is Label and String(root.text).contains(text_fragment): + return {"x": root.global_position.x, "y": root.global_position.y, + "w": root.size.x, "h": root.size.y} + for c in root.get_children(): + var r := _find_label_rect(c, text_fragment) + if not r.is_empty(): + return r + return {} + + +func _node_rect(n: Node) -> Dictionary: + if n == null or not (n is CanvasItem): + return {} + return {"x": n.global_position.x, "y": n.global_position.y, + "w": n.size.x, "h": n.size.y} + + +func _find_by_script(root: Node, script_name: String) -> Node: + if root == null: + return null + var s: Script = root.get_script() + if s != null and String(s.resource_path).contains(script_name): + return root + for c in root.get_children(): + var r := _find_by_script(c, script_name) + if r != null: + return r + return null diff --git a/tools/shot_checker.py b/tools/shot_checker.py index f49d35c4..638b7eea 100644 --- a/tools/shot_checker.py +++ b/tools/shot_checker.py @@ -1,20 +1,79 @@ #!/usr/bin/env python3 -r"""shot_checker.py — 整屏截图 B 档断言骨架(迭代 9 卡⑰a 2026-09-30)。 +r"""shot_checker.py — 整屏样张闸机检(迭代 9 卡⑤/T8 2026-09-30;B 档断言骨架扩全)。 -用法: python tools/shot_checker.py <截图目录> [shot_menu.png shot_camp.png shot_level.png shot_battle.png] -断言(v1 骨架——像素级可判层): - ① 四屏 PNG 存在且尺寸 = 1280×800(工程逻辑分辨率档); - ② 每屏灰度标准差 ≥ 8(非空白/非纯色——渲染真实发生的最低证据); - ③ 每屏唯一色数 ≥ 64(内容丰富度下限——占位纯色屏会落在个位数)。 -T8 整屏样张闸四断言(全局文字 ≥4.5:1/明度三档带/焦点预算/文字禁压纹理)在本骨架上扩。 +用法: python tools/shot_checker.py <截图目录> [屏清单...] +默认屏清单 = shot_menu.png shot_camp.png shot_level.png shot_battle.png(同目录 shot_meta.json += shot_probe 输出的控件实测几何,为文字框/明度带取样坐标源——分辨率档位自适应,禁硬编码像素)。 + +断言(整屏样张闸 §1.4 四项的机检可判面): + A 屏级骨架: PNG 存在/尺寸=工程档/灰度 std≥8/唯一色数≥64(渲染真实发生); + B 文字对比度: 已知文字框〔主墨/暖白 文字簇 vs 背景簇 p2/p98 极性无关〕WCAG ≥4.5:1 + (菜单副标题+帮助文/战斗 C 区面板/结算明细提示——圣经 v3.5 §7.2 全局文字硬指标机检面); + C 明度三档带: 战斗屏 信息面(C 区面板均值) > 世界中(棋盘区均值) > 世界底(舞台无光柱区均值) + (§2 明度分层——层间灰度可分); + D 文字禁压纹理: 各文字框背景主簇纯度 ≥0.5(±12 灰度带占比——文字下垫平滑容器非 busy 纹理); + E 焦点预算〔首焦唯一〕: 语义面无法机检 → 编入段闸实机目检清单(T15 人闸判定)。 退出码: 0 = 全过 / 1 = 有失败。 """ +import json import sys from pathlib import Path from PIL import Image -EXPECTED = (1280, 800) +EXPECTED_SHORT = 1280 # 工程基准档短边(1920 档 = 1.5×——尺寸断言按短边判档) + + +def _srgb_lin(c: float) -> float: + c /= 255.0 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def wcag(l1: float, l2: float) -> float: + a, b = _srgb_lin(l1), _srgb_lin(l2) + hi, lo = max(a, b), min(a, b) + return (hi + 0.05) / (lo + 0.05) + + +def box_of(meta: dict, key: str, pad: int = 4) -> tuple: + r = meta.get(key, {}) + if not r: + return tuple() + return (max(0, int(r["x"]) - pad), max(0, int(r["y"]) - pad), + int(r["x"] + r["w"]) + pad, int(r["y"] + r["h"]) + pad) + + +def rel_box(meta: dict, key: str, fx0: float, fy0: float, fx1: float, fy1: float) -> tuple: + r = meta.get(key, {}) + if not r: + return tuple() + x, y, w, h = r["x"], r["y"], r["w"], r["h"] + return (int(x + w * fx0), int(y + h * fy0), int(x + w * fx1), int(y + h * fy1)) + + +def box_pixels(img: Image.Image, box: tuple) -> list: + return list(img.crop(box).convert("L").getdata()) + + +def check_text_box(img: Image.Image, box: tuple, name: str, failures: list) -> None: + if not box: + failures.append("%s: meta 几何缺失" % name) + return + px = sorted(box_pixels(img, box)) + n = len(px) + lo, hi = px[int(n * 0.02)], px[min(n - 1, int(n * 0.98))] + ratio = wcag(lo, hi) + mid = px[int(n * 0.2):int(n * 0.8) + 1] + mid_mean = sum(mid) / len(mid) + mid_std = (sum((v - mid_mean) ** 2 for v in mid) / len(mid)) ** 0.5 + ok_c = ratio >= 4.5 + ok_t = mid_std <= 18.0 # 文字禁压纹理=背景纹理幅度判据(中带 std——设计内低幅石纹 ≈3 过、busy 纹理 25+ 拒) + print("[shot_checker] %s — WCAG=%.2f 纹理幅度=%.1f %s%s" % ( + name, ratio, mid_std, "C-OK" if ok_c else "C-FAIL", "" if ok_t else " T-FAIL")) + if not ok_c: + failures.append("%s: 文字对比度 %.2f < 4.5" % (name, ratio)) + if not ok_t: + failures.append("%s: 背景纹理幅度 %.1f > 18(文字压 busy 纹理)" % (name, mid_std)) def main() -> int: @@ -23,35 +82,63 @@ def main() -> int: return 1 d = Path(sys.argv[1]) shots = sys.argv[2:] or ["shot_menu.png", "shot_camp.png", "shot_level.png", "shot_battle.png"] + meta = {} + meta_p = d / "shot_meta.json" + if meta_p.exists(): + meta = json.loads(meta_p.read_text(encoding="utf-8")) failures = [] + imgs = {} for name in shots: p = d / name if not p.exists(): failures.append("%s: 文件缺失" % name) continue img = Image.open(p).convert("RGB") - if img.size != EXPECTED: - failures.append("%s: 尺寸 %s != %s" % (name, img.size, EXPECTED)) + imgs[name] = img + if img.size[0] != EXPECTED_SHORT and img.size[1] != EXPECTED_SHORT: + failures.append("%s: 尺寸 %s 非工程档(短边应 %d)" % (name, img.size, EXPECTED_SHORT)) continue gray = img.convert("L") hist = gray.histogram() total = sum(hist) mean = sum(i * c for i, c in enumerate(hist)) / total - var = sum(c * (i - mean) ** 2 for i, c in enumerate(hist)) / total - std = var ** 0.5 - colors = len({p2 for p2 in img.getdata()}) + std = (sum(c * (i - mean) ** 2 for i, c in enumerate(hist)) / total) ** 0.5 + colors = len(set(img.getdata())) if std < 8.0: failures.append("%s: 灰度 std %.1f < 8(疑似空白/纯色)" % (name, std)) if colors < 64: failures.append("%s: 唯一色数 %d < 64(疑似占位屏)" % (name, colors)) - print("[shot_checker] %s — %s std=%.1f colors=%d %s" % ( - name, "x".join(map(str, img.size)), std, colors, "OK" if std >= 8 and colors >= 64 else "FAIL")) + print("[shot_checker] %s — %s std=%.1f colors=%d" % (name, "x".join(map(str, img.size)), std, colors)) + + # B+C+D 断言(battle/menu 两屏为机检承载屏;camp/level 走骨架+目检清单) + if "shot_battle.png" in imgs: + b = imgs["shot_battle.png"] + check_text_box(b, box_of(meta, "hero_panel"), "战斗·C区面板文字", failures) + check_text_box(b, box_of(meta, "battle_log_hint"), "战斗·明细带提示", failures) + # 明度三档带(信息面 > 世界中 > 世界底) + info_box = box_of(meta, "hero_panel", pad=0) + world_box = rel_box(meta, "board_zone", 0.25, 0.3, 0.75, 0.8) + bottom_box = rel_box(meta, "stage_view", 0.25, 0.3, 0.45, 0.85) + if info_box and world_box and bottom_box: + lm = [sum(box_pixels(b, bx)) / max(1, len(box_pixels(b, bx))) for bx in (info_box, world_box, bottom_box)] + ok = lm[0] > lm[1] > lm[2] + print("[shot_checker] 明度三档带 — 信息面=%.0f 世界中=%.0f 世界底=%.0f %s" % ( + lm[0], lm[1], lm[2], "OK" if ok else "FAIL")) + if not ok: + failures.append("明度三档带: %.0f/%.0f/%.0f 未成 info>world>bottom 序" % tuple(lm)) + else: + failures.append("明度三档带: meta 几何缺失") + if "shot_menu.png" in imgs: + m = imgs["shot_menu.png"] + check_text_box(m, box_of(meta, "menu_sub"), "菜单·副标题", failures) + check_text_box(m, box_of(meta, "menu_help"), "菜单·帮助文", failures) + print("[shot_checker] 焦点预算〔首焦唯一〕= 目检项(T15 段闸人闸清单判定——语义面不机检)") if failures: print("[shot_checker] FAIL——") for f in failures: print(" - %s" % f) return 1 - print("[shot_checker] PASS(%d 屏骨架断言全过)" % len(shots)) + print("[shot_checker] PASS(整屏样张闸机检面全过;焦点预算归目检)") return 0