From 3d04432f7a43265db2f1c902af94ffd7e107b3a4 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 8 Oct 2026 07:18:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=8D=A1=E2=91=A0T1?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=B8=B2=E6=9F=93=E9=97=B8=20tools/render=5F?= =?UTF-8?q?gate.sh=20=E8=90=BD=E7=A0=81+shot=5Fprobe=20=E6=89=A9=E5=B1=8F?= =?UTF-8?q?=E4=B8=83=E5=B1=8F=EF=BC=88=E8=AE=BE=E7=BD=AE=E9=9D=A2/?= =?UTF-8?q?=E7=94=9F=E6=B6=AF=E9=A1=B5/=E6=9A=82=E5=81=9C=E5=B1=82?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E4=BF=A1=E5=8F=B7=E8=B7=AF=E5=BE=84=E5=85=A5?= =?UTF-8?q?=E5=B1=8F=EF=BC=8C=E5=95=86=E5=BA=97=E6=88=BF=E8=80=83=E8=AF=81?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=BE=BE=E6=B3=A8=E8=AE=B0=E7=95=99=20B=20?= =?UTF-8?q?=E6=89=B9=EF=BC=89+board=5Fshot=5Fprobe=20=E5=8F=96=E8=AF=81?= =?UTF-8?q?=E4=B8=89=E4=BB=B6=E5=A5=97=EF=BC=88headless=20=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F/=E7=A9=BA=E7=BA=B9=E7=90=86=20fail/board=5Fmeta.json?= =?UTF-8?q?=20=E5=8F=96=E6=A0=B7=E5=9D=90=E6=A0=87=E8=90=BD=E7=9B=98?= =?UTF-8?q?=EF=BC=89+golden=5Fboard=5Fcrop/golden=5Fbaseline=5Frefresh=20?= =?UTF-8?q?=E4=B8=A4=E8=BE=85=E5=8A=A9=E4=BB=B6=EF=BC=9B=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=A1=A3=E5=8D=B3=E6=97=B6=20apply=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=9C=9F=E5=AE=9E=E6=A1=A3=20fullscreen=20=E6=80=81?= =?UTF-8?q?=E6=B1=A1=E6=9F=93=E6=8A=93=E5=B8=A7=E5=B0=BA=E5=AF=B8=202304x1?= =?UTF-8?q?440=E2=89=A01280=20=E5=9F=BA=E5=87=86=E6=A1=A3=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=8E=A2=E9=92=88=E4=BE=A7=E7=8E=AF=E5=A2=83=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E9=9B=B6=E8=BF=90=E8=A1=8C=E6=97=B6=20diff=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E5=8F=96=E8=AF=81=E6=AE=B5=E5=A4=B1=E8=B4=A5=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=3D=E9=97=B8=E7=BA=A2=EF=BC=88Q7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/smoke/board_shot_probe.gd | 20 +++++ game/smoke/fx_shot_probe.gd | 2 + game/smoke/shot_probe.gd | 83 ++++++++++++++++-- tools/golden_baseline_refresh.sh | 42 +++++++++ tools/golden_board_crop.py | 64 ++++++++++++++ tools/render_gate.sh | 144 +++++++++++++++++++++++++++++++ 6 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 tools/golden_baseline_refresh.sh create mode 100644 tools/golden_board_crop.py create mode 100644 tools/render_gate.sh diff --git a/game/smoke/board_shot_probe.gd b/game/smoke/board_shot_probe.gd index b340052a..06a1084b 100644 --- a/game/smoke/board_shot_probe.gd +++ b/game/smoke/board_shot_probe.gd @@ -4,12 +4,22 @@ # Godot_console.exe --path . res://smoke/board_shot_probe.tscn -- <输出目录> # 流程:L1/L2/L3 各起一板(真实 LevelRunner+settle_batch 驱动 ~18 步 novice——自然揭示数字/旗), # BoardView 真渲染,frame_post_draw 同步后抓视口 PNG。零存档触碰(无 save_manager 引用)。 +# 迭代 19 卡① T1/T2(2026-10-08):①headless fail-fast 护栏(shot_probe 同款); +# ②空纹理取证 fail(Q7——取证段失败=闸红,零字节/空纹理不允许静默落盘); +# ③board_meta.json 取样坐标落盘(棋盘视图全局矩形——golden 前置裁剪坐标源,禁硬编码像素)。 extends Node const AutoPlayer := preload("res://smoke/auto_player.gd") +var _meta := {} # 棋盘视图实测几何(全局 rect——tools/golden_board_crop.py 取样坐标源) + func _ready() -> void: + # 运行前提=窗口化真渲染面(shot_probe 同款护栏——headless 下 frame_post_draw 永不触发+视口纹理恒空) + if DisplayServer.get_name() == "headless": + print("[board_shot] FAIL — 本探针必须窗口化运行(headless 无渲染面,截图必空);正主=tools/render_gate.sh 取证段") + get_tree().quit(1) + return var args := OS.get_cmdline_user_args() var out := args[0] if args.size() >= 1 and args[0] != "" else "user://board-shots" DirAccess.make_dir_recursive_absolute(out) @@ -53,10 +63,20 @@ func _ready() -> void: await get_tree().process_frame await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() + if img == null or img.is_empty(): + push_error("[board_shot] FAIL — 抓帧空纹理: %s(Q7 取证语义=闸红)" % tag) + get_tree().quit(1) + return var path := "%s/board_%s.png" % [out, tag] img.save_png(path) + _meta[tag] = {"x": view.global_position.x, "y": view.global_position.y, + "w": view.size.x, "h": view.size.y} print("[board_shot] saved %s (%dx%d)" % [path, img.get_width(), img.get_height()]) view.queue_free() await get_tree().process_frame + var mf := FileAccess.open(out.path_join("board_meta.json"), FileAccess.WRITE) + if mf != null: + mf.store_string(JSON.stringify(_meta)) + mf.close() print("[board_shot] done") get_tree().quit(0) diff --git a/game/smoke/fx_shot_probe.gd b/game/smoke/fx_shot_probe.gd index 4c346b91..c29fddd4 100644 --- a/game/smoke/fx_shot_probe.gd +++ b/game/smoke/fx_shot_probe.gd @@ -29,6 +29,8 @@ func _ready() -> void: add_child(main) await get_tree().process_frame ProbeUiUtil.isolate_save(main, "fx_shot_probe", false, true, false) + # 隔离档即时应用(迭代 19 卡① T1——真实档 fullscreen 态会污染抓帧尺寸〔shot_probe 同款修正〕) + main.settings_store.apply() for i in 8: await get_tree().process_frame # UI 装配完成等足帧(canvas_item 空引用窗口) var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains") diff --git a/game/smoke/shot_probe.gd b/game/smoke/shot_probe.gd index f2d04471..d33c2a8e 100644 --- a/game/smoke/shot_probe.gd +++ b/game/smoke/shot_probe.gd @@ -1,12 +1,17 @@ # VERTICAL SLICE - NOT FOR PRODUCTION -# 整屏截图探针(迭代 9 卡⑰a 2026-09-30——呈现层测试分级 B 档基建:四屏视口截图程序化产出)。 +# 整屏截图探针(迭代 9 卡⑰a 2026-09-30——呈现层测试分级 B 档基建:视口截图程序化产出; +# 迭代 19 卡① T1 2026-10-08 扩屏四屏→七屏——新纳 设置面/生涯页/暂停层,正主=tools/render_gate.sh)。 # 必须**窗口化**运行(真渲染设备——headless 无渲染面,get_image 得到空纹理): # Godot_console.exe --path . res://smoke/shot_probe.tscn -- <输出目录> # 流程 = 真实按钮信号路径驱动(ui_click_probe 同款;存档/设置/元进度三面换注隔离档——禁碰真实档, -# 坑册「断点写盘随 save_manager 引用走」教训同款覆盖):菜单(启动态)→ 营地(campbtn)→ -# 关卡画面(lvbtn→停点出图)→ 战斗(level_screen.press_candidate(0) 真实选路提交)。 +# 坑册「断点写盘随 save_manager 引用走」教训同款覆盖):菜单(启动态)→ 设置面(settingsbtn +# 真实信号路径)→ 生涯页(careerbtn 同款)→ 营地(campbtn)→ 关卡画面(lvbtn→停点出图)→ +# 战斗(level_screen.press_candidate(0) 真实选路提交)→ 暂停层(地图屏暂停角标真实信号路径)。 # 每屏双帧 + frame_post_draw 同步后抓 get_viewport().get_texture(),PNG 落输出目录; -# 最小自检 = 四件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。 +# 最小自检 = 七件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。 +# 商店房注记(迭代 19 卡① T1 考证结论,代拍板白名单授权):真实路径必经 L1 打穿→L2 商店节点 +# 选路(ui_click_probe 实证商店层在 L2 Boss 前必经位)——探针现行驱动只进首战节点,低代价确定性 +# 到达不成立;合成投喂(ui_struct_probe 同款)破坏本探针真实路径口径 → 不纳屏,注记留 B 批。 extends Node const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 @@ -32,12 +37,57 @@ func _ready() -> void: await get_tree().process_frame # ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染;历史语义 = contains 寻址) ── ProbeUiUtil.isolate_save(main, "smoke_shot_probe", false, true, false) + # 隔离档即时应用(迭代 19 卡① T1——main 装配已按真实设置恢复过窗口态〔本机实测:真实档 + # fullscreen=true 时窗口进全屏,视口纹理 2304x1440≠1280 基准档,checker 尺寸/取样全盘失真〕; + # apply 隔离档出厂默认=窗口化 1280x800,恢复本探针设计前提。探针侧环境修正,零运行时改动) + main.settings_store.apply() await get_tree().process_frame # ① 菜单(启动态) _meta["menu_sub"] = ProbeUiUtil.label_rect(main._start_overlay, "改造批") _meta["menu_help"] = ProbeUiUtil.label_rect(main._start_overlay, "玩法:") _meta["menu_title"] = ProbeUiUtil.label_rect(main._start_overlay, "Dungeon Sweeper") await _snap("menu") + # ①b 设置面(迭代 19 卡① T1 扩屏——settingsbtn 真实信号路径,开合同款真实钮) + var setbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "设置", "prefix") + if setbtn == null: + _problems.append("设置入口缺失(开始层 settingsbtn)") + else: + setbtn.pressed.emit() + for i in 3: + await get_tree().process_frame + if not main.settings_overlay.visible: + _problems.append("设置面未打开(settingsbtn 路径)") + else: + _meta["settings_title"] = ProbeUiUtil.label_rect(main.settings_overlay, "设置") + await _snap("settings") + var set_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix") + if set_close == null: + _problems.append("设置面返回钮缺失") + else: + set_close.pressed.emit() + await get_tree().process_frame + # ①c 生涯页(迭代 19 卡① T1 扩屏——careerbtn 真实信号路径,ui_struct_probe 同款入口链) + var careerbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "生涯", "prefix") + if careerbtn == null: + _problems.append("生涯入口缺失(开始层 careerbtn)") + else: + careerbtn.pressed.emit() + for i in 3: + await get_tree().process_frame + if not main.career_overlay.visible or main._start_overlay.visible: + _problems.append("生涯页未打开或开始层未隐藏(careerbtn 路径)") + else: + _meta["career_title"] = ProbeUiUtil.label_rect(main.career_overlay, "生 涯") + await _snap("career") + var career_close: Button = ProbeUiUtil.find_button(main.career_overlay, "返回", "prefix") + if career_close == null: + _problems.append("生涯页返回钮缺失") + else: + career_close.pressed.emit() + for i in 3: + await get_tree().process_frame + if main.career_overlay.visible or not main._start_overlay.visible: + _problems.append("生涯页关闭后页未隐藏或开始层未恢复") # ② 营地 var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "contains") if campbtn == null: @@ -84,6 +134,27 @@ func _ready() -> void: var dc := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) _meta["battle_draw_calls"] = dc print("[shot_probe] battle draw_calls=%d(基线 270/目标 <200)" % dc) + # ⑤ 暂停层(迭代 19 卡① T1 扩屏——战斗屏暂停角标真实信号路径;层在战斗屏之上开合) + var pb: Button = main.board_zone.pause_badge + if pb == null or not pb.visible or pb.disabled: + _problems.append("暂停入口角标缺失/置灰(战斗屏 board_zone)") + else: + pb.pressed.emit() + for i in 3: + await get_tree().process_frame + if not main.pause_overlay.visible: + _problems.append("暂停层未打开(角标路径)") + else: + _meta["pause_title"] = ProbeUiUtil.label_rect(main.pause_overlay, "暂停") + await _snap("pause") + var pause_close: Button = ProbeUiUtil.find_button(main.pause_overlay, "继续", "prefix") + if pause_close == null: + _problems.append("暂停层继续钮缺失") + else: + pause_close.pressed.emit() + await get_tree().process_frame + if main.pause_overlay.visible: + _problems.append("暂停层关闭后未隐藏(继续钮路径)") else: _problems.append("关卡画面未出现(lvbtn 路径)") # ── 几何元数据落盘(shot_checker 取样坐标源) ── @@ -91,8 +162,8 @@ func _ready() -> void: if mf != null: mf.store_string(JSON.stringify(_meta)) mf.close() - # ── 最小自检:四件存在且非零字节 ── - for s in ["menu", "camp", "level", "battle"]: + # ── 最小自检:七件存在且非零字节(迭代 19 卡① T1 扩屏后口径) ── + for s in ["menu", "settings", "career", "camp", "level", "battle", "pause"]: var p := out_dir.path_join("shot_%s.png" % s) if not FileAccess.file_exists(p): _problems.append("截图缺失: %s" % p) diff --git a/tools/golden_baseline_refresh.sh b/tools/golden_baseline_refresh.sh new file mode 100644 index 00000000..0d61f280 --- /dev/null +++ b/tools/golden_baseline_refresh.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 棋盘 golden 基线换代辅助(迭代 19 卡① T2 2026-10-08——呈现类改动批专用)。 +# 权限口径(Q6):呈现类改动批内 agent 自行换代+提交说明**强制写明触发原因**;数值/逻辑批禁碰。 +# 前置条件(Q5/S3):素材必须齐盘(字体/棋盘瓦片/英雄/旗纹理——本脚本逐项守卫, +# 缺位即拒跑退出 1=基线禁锚字形回退态;缺位时先拷主区素材+导入边车再跑)。 +# 流程:窗口化 board_shot_probe 抓 L1-L3 整屏帧+board_meta.json → golden_board_crop.py +# 按 meta 取样坐标裁棋盘区(禁硬编码像素)→ 覆盖 game/tests/golden/ 三张基线。 +# 过程截图落 mktemp 临时区即弃(Q2),只有裁剪产物入库。 +# 用法: tools/golden_baseline_refresh.sh [game目录] 触发原因写进当批提交说明(强制)。 +set -u +G="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}" +REPO="$(cd "$(dirname "$0")/.." && pwd)" +GAME="${1:-$REPO/game}" +GAME_WIN="$(cygpath -m "$GAME")" +TMP="$(mktemp -d /tmp/goldenrefresh_XXXXXX)" +trap 'rm -rf "$TMP"' EXIT + +for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ + assets/art/board/flag_seal.png \ + assets/art/character/character_hero_idle.png \ + assets/art/board/terrain_wall_n_dungeon.png assets/art/board/terrain_floor_dungeon_1.png \ + assets/art/board/terrain_wall_n_underdark.png assets/art/board/terrain_floor_underdark_1.png \ + assets/art/board/terrain_wall_n_deep_underdark.png assets/art/board/terrain_floor_deep_underdark_1.png; do + if [ ! -f "$GAME/$a" ]; then + echo "[golden_refresh] 拒跑 — 素材缺位: $a(S3 停机点口径:基线禁锚字形回退态,先补素材+导入边车)" + exit 1 + fi +done + +timeout 180 "$G" --path "$GAME_WIN" res://smoke/board_shot_probe.tscn -- "$TMP/shots" >"$TMP/run.log" 2>&1 +st=$? +grep -E "\[board_shot\]" "$TMP/run.log" | tail -6 +if [ "$st" -ne 0 ] || [ ! -s "$TMP/shots/board_meta.json" ]; then + echo "[golden_refresh] FAIL — board_shot_probe 退出码=$st(meta/截图缺失见上)" + exit 1 +fi +python tools/golden_board_crop.py "$TMP/shots/board_meta.json" "$TMP/shots" "$TMP/crop" || exit 1 +mkdir -p "$REPO/game/tests/golden" +for b in board_L1_dungeon.png board_L2_underdark.png board_L3_deep_underdark.png; do + cp "$TMP/crop/$b" "$REPO/game/tests/golden/$b" || exit 1 +done +echo "[golden_refresh] 完成 — 三张基线已换代入 game/tests/golden/(提交说明须写明触发原因)" diff --git a/tools/golden_board_crop.py b/tools/golden_board_crop.py new file mode 100644 index 00000000..e74e8f5e --- /dev/null +++ b/tools/golden_board_crop.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +r"""golden_board_crop.py — 棋盘 golden 前置裁剪(迭代 19 卡① T2 2026-10-08)。 + +board_shot_probe 截整屏视口(get_viewport().get_texture()),golden 逐像素对比前须裁出棋盘区。 +本工具按 board_meta.json 取样坐标(board_shot_probe 输出的棋盘视图实测全局矩形)裁剪, +**禁硬编码像素**(迭代 15③ 挂账口径——布局改动只须重跑探针刷新 meta,裁剪代码零改动)。 + +用法: python tools/golden_board_crop.py <截图目录> <输出目录> +输出: <输出目录>/board_.png(tag=meta 键,如 L1_dungeon)。 + +容错:矩形越界按图像边界收拢;meta 键缺失/矩形退化(宽高≤0)= 报错退出 1(取证缺失不静默)。 +退出码: 0 = 全部裁出 / 1 = 有失败。 +""" +import json +import sys +from pathlib import Path + +from PIL import Image + + +def main() -> int: + if len(sys.argv) != 4: + print("用法: python tools/golden_board_crop.py <截图目录> <输出目录>") + return 1 + meta_p, src_d, dst_d = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3]) + if not meta_p.exists(): + print("[golden_crop] FAIL — board_meta.json 缺失: %s" % meta_p) + return 1 + meta = json.loads(meta_p.read_text(encoding="utf-8")) + if not meta: + print("[golden_crop] FAIL — board_meta.json 空表(取样坐标零在案)") + return 1 + dst_d.mkdir(parents=True, exist_ok=True) + failures = [] + for tag, r in meta.items(): + src = src_d / ("board_%s.png" % tag) + if not src.exists(): + failures.append("%s: 截图缺失 %s" % (tag, src)) + continue + img = Image.open(src).convert("RGB") + try: + x, y, w, h = int(r["x"]), int(r["y"]), int(r["w"]), int(r["h"]) + except (KeyError, TypeError, ValueError): + failures.append("%s: meta 矩形字段缺失/非法 %s" % (tag, r)) + continue + # 越界收拢到图像边界(全局 rect 可能触视口边) + box = (max(0, x), max(0, y), min(img.size[0], x + w), min(img.size[1], y + h)) + if box[2] - box[0] <= 0 or box[3] - box[1] <= 0: + failures.append("%s: 矩形退化 %s(img %s)" % (tag, box, img.size)) + continue + img.crop(box).save(dst_d / ("board_%s.png" % tag)) + print("[golden_crop] %s — %s -> %s(%dx%d)" % (tag, box, dst_d / ("board_%s.png" % tag), + box[2] - box[0], box[3] - box[1])) + if failures: + print("[golden_crop] FAIL——") + for f in failures: + print(" - %s" % f) + return 1 + print("[golden_crop] PASS(%d 张棋盘区裁剪全出)" % len(meta)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/render_gate.sh b/tools/render_gate.sh new file mode 100644 index 00000000..5810cf17 --- /dev/null +++ b/tools/render_gate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# 渲染闸正主(迭代 19 卡① 自动化验收补齐批 2026-10-08 落码——T1/T2)——窗口化自动闸。 +# 迭代 15③ 挂账「渲染五闸」机器补跑半随本闸落籍(shot_probe/golden_compare/board_shot 三件入闸; +# fx_probe 逻辑层归 probe_gate --full、ui_layout_probe 归其自身两档断言——归宿对账见卡①任务书 §一)。 +# 骨架复用 probe_gate:①每段 timeout 守卫(挂死=FAIL 不静默)②段前预清+段后清本 game 目录孤儿 +# Godot(按命令行精确匹配本 game 路径,不误杀其他 worktree 会话)③末尾残留清点=批末闸(必须 0)。 +# 段: +# ① shot_probe 七屏窗口化截图(menu/settings/career/camp/level/battle/pause——迭代 19 卡① T1 扩屏) +# → tools/shot_checker.py A-D 语义断言自动判定(A 屏级骨架全屏/B 文字对比度/C 明度三档带/ +# D 文字禁压纹理;E 焦点预算=语义面仍归人闸不机检)。首跑存量 FAIL 按 S1 停机点全量呈报登记 06。 +# ② 取证段(Q7:超时/零字节/文件缺失任一即 FAIL——「渲染真实发生」必须成立,不做像素判定): +# fx_shot_probe 三时刻抓帧(行到达/结算排播/轮末连帧)+ board_shot_probe L1-L3 棋盘抓帧。 +# ③ 棋盘 golden 逐像素(T2):board 整屏帧按 board_meta.json 取样坐标裁剪棋盘区 +# (tools/golden_board_crop.py——禁硬编码像素)→ tools/golden_compare.py 对 +# game/tests/golden/ 三张基线(容差 provisional:差 ≤16/changed ≤2%,S2 停机点口径)。 +# 素材缺位=SKIP 不判红(Q5 运行时口径;基线生成期素材缺位=L3 停机——基线禁锚回退态)。 +# ④ 基准档 1280 单档(Q4)。1920 档注记:16:9 全屏下 stretch=canvas_items/aspect=keep 等比 +# 1.35×+左右黑边各 96px,构图与基准档一致(迭代 9 分辨率案甲)——分辨率维度不重复建闸, +# ui_layout_probe 两档断言(T5)承担 1920 布局机检面。 +# 过程产物纪律(Q2):一切截图 PNG 落 mktemp 临时区,闸毕即弃——任何过程 PNG 禁入 git; +# 例外=game/tests/golden/ 基线(测试资产入库,.gitignore 已核不挡)。 +# 窗口化运行会短暂弹 Godot 窗口(本机可开窗口径,2026-10-07 用户令);禁改 --headless 跑本闸 +# (headless 无渲染面,探针内 fail-fast 护栏会红——那是护栏生效不是闸红)。 +# 用法: tools/render_gate.sh [game目录] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree) +# 依赖: Git Bash(coreutils timeout/taskkill) + PowerShell + python(PIL);Godot 路径可用环境变量 GODOT_BIN 覆盖。 +set -u +G="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}" +REPO="$(cd "$(dirname "$0")/.." && pwd)" +GAME="${1:-$REPO/game}" +GAME_WIN="$(cygpath -m "$GAME")" # Godot --path 需 Windows 形式 +TMP="$(mktemp -d /tmp/rendergate_XXXXXX)" +trap 'rm -rf "$TMP"' EXIT +_rc=0 + +orphans() { # 列出命令行含本 game 路径的 Godot 进程 PID(console wrapper+真引擎都算) + printf "Get-CimInstance Win32_Process -Filter \"Name like 'Godot%%'\" | Where-Object { \$_.CommandLine -like '*%s*' } | ForEach-Object { \$_.ProcessId }\n" "$GAME_WIN" > "$TMP/k.ps1" + powershell -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -w "$TMP/k.ps1")" 2>/dev/null | tr -d '\r' +} +kill_orphans() { + local p; for p in $(orphans); do taskkill //F //PID "$p" >/dev/null 2>&1; done +} + +# 冷检出刷导入(.godot 不入 git,fresh worktree 首跑一次性付) +if [ ! -f "$GAME/.godot/uid_cache.bin" ]; then + echo "[render_gate] 冷检出:editor --quit 刷导入(一次性,可到 1-2 分钟)" + timeout 300 "$G" --headless --path "$GAME_WIN" --editor --quit >/dev/null 2>&1 + kill_orphans +fi + +pre="$(orphans | wc -l | tr -d ' ')" +if [ "$pre" -gt 0 ]; then echo "[render_gate] 预清本闸残留 Godot×$pre"; kill_orphans; fi + +echo "======== shot_probe 七屏截图 → shot_checker A-D ========" +SHOT="$TMP/shots" +timeout 180 "$G" --path "$GAME_WIN" res://smoke/shot_probe.tscn -- "$SHOT" >"$TMP/shot.log" 2>&1 +st=$?; kill_orphans +grep -E "\[shot_probe\]" "$TMP/shot.log" | tail -12 +if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then + echo "[render_gate] FAIL — shot_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 +elif [ "$st" -ne 0 ] || ! grep -q "问题=0" "$TMP/shot.log"; then + echo "[render_gate] FAIL — shot_probe 未干净完成(exit=$st,缺失屏/空文件/路径断裂见上)"; _rc=1 +fi +if [ "$_rc" -eq 0 ]; then + python tools/shot_checker.py "$SHOT" shot_menu.png shot_settings.png shot_career.png shot_camp.png shot_level.png shot_battle.png shot_pause.png | sed 's/^/[render_gate] /' + _rc="${PIPESTATUS[0]}" + [ "$_rc" -ne 0 ] && echo "[render_gate] FAIL — shot_checker A-D 断言存在红(S1 停机点:首跑存量 FAIL 全量呈报登记 06)" +fi + +echo "======== fx_shot_probe 三时刻取证(Q7) ========" +FX="$TMP/fxshots" +timeout 180 "$G" --path "$GAME_WIN" res://smoke/fx_shot_probe.tscn -- "$FX" >"$TMP/fx.log" 2>&1 +st=$?; kill_orphans +grep -E "\[fx_shot_probe\]" "$TMP/fx.log" | tail -4 +if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then + echo "[render_gate] FAIL — fx_shot_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 +elif [ "$st" -ne 0 ]; then + echo "[render_gate] FAIL — fx_shot_probe 退出码=$st(取证失败=闸红)"; _rc=1 +fi +fx_evidence() { # 取证双检:模式文件存在且非零字节(零字节/缺失任一即 FAIL——Q7) + local pat="$1" min="$2" n=0 f + for f in "$FX"/$pat; do + [ -f "$f" ] || continue + [ -s "$f" ] || { echo "[render_gate] FAIL — 取证零字节: $f"; _rc=1; return; } + n=$((n+1)) + done + if [ "$n" -lt "$min" ]; then echo "[render_gate] FAIL — 取证文件缺失: $pat 仅 $n/<$min"; _rc=1; fi +} +fx_evidence "fx_dig*.png" 1 +fx_evidence "fx_settle*.png" 1 +fx_evidence "fx_round*.png" 6 +fx_evidence "fx_final.png" 1 + +echo "======== board_shot_probe L1-L3 取证(Q7) ========" +BD="$TMP/boardshots" +timeout 180 "$G" --path "$GAME_WIN" res://smoke/board_shot_probe.tscn -- "$BD" >"$TMP/bd.log" 2>&1 +st=$?; kill_orphans +grep -E "\[board_shot\]" "$TMP/bd.log" | tail -6 +if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then + echo "[render_gate] FAIL — board_shot_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 +elif [ "$st" -ne 0 ]; then + echo "[render_gate] FAIL — board_shot_probe 退出码=$st(取证失败=闸红)"; _rc=1 +fi +for b in board_L1_dungeon.png board_L2_underdark.png board_L3_deep_underdark.png board_meta.json; do + if [ ! -s "$BD/$b" ]; then echo "[render_gate] FAIL — 取证缺失/零字节: $b"; _rc=1; fi +done + +echo "======== 棋盘 golden 逐像素(T2——裁剪→对比,容差 provisional) ========" +if [ ! -d "$REPO/game/tests/golden" ]; then + echo "[render_gate] FAIL — game/tests/golden/ 基线目录缺失(基线在库=验收断言②)"; _rc=1 +elif [ ! -s "$BD/board_meta.json" ]; then + echo "[render_gate] SKIP — board_meta.json 缺失(取证段已红),golden 对比不判" +else + # Q5 运行时素材缺位=SKIP:基线锚在素材齐盘态,缺盘态对比必误红——SKIP 不判红,注记在案 + _asset_miss=0 + for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ + assets/art/board/flag_seal.png \ + assets/art/character/character_hero_idle.png \ + assets/art/board/terrain_wall_n_dungeon.png assets/art/board/terrain_floor_dungeon_1.png \ + assets/art/board/terrain_wall_n_underdark.png assets/art/board/terrain_floor_underdark_1.png \ + assets/art/board/terrain_wall_n_deep_underdark.png assets/art/board/terrain_floor_deep_underdark_1.png; do + [ -f "$GAME/$a" ] || { echo "[render_gate] SKIP — 棋盘素材缺位: $a(Q5——基线禁锚回退态,运行时 SKIP)"; _asset_miss=1; break; } + done + if [ "$_asset_miss" -eq 0 ]; then + CROP="$TMP/crop" + python tools/golden_board_crop.py "$BD/board_meta.json" "$BD" "$CROP" | sed 's/^/[render_gate] /' + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + echo "[render_gate] FAIL — 棋盘区裁剪失败"; _rc=1 + else + python tools/golden_compare.py "$REPO/game/tests/golden" "$CROP" \ + board_L1_dungeon.png board_L2_underdark.png board_L3_deep_underdark.png | sed 's/^/[render_gate] /' + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + echo "[render_gate] FAIL — golden 逐像素超容差(S2 停机点:字体/平台误红呈报后定容差)"; _rc=1 + fi + fi + fi +fi + +echo "======== 残留检查(批末闸) ========" +left="$(orphans | wc -l | tr -d ' ')" +total="$(tasklist 2>/dev/null | grep -ci '^Godot' || true)" +echo "本闸残留 Godot=$left(必须0) 全局 Godot 进程=$total(其他会话的仅报数不算账)" +if [ "$left" -ne 0 ]; then echo "[render_gate] FAIL — 本闸残留未清零"; kill_orphans; _rc=1; fi +if [ "$_rc" -eq 0 ]; then echo "[render_gate] 全绿"; else echo "[render_gate] 存在 FAIL,见上"; fi +exit "$_rc"