extends Node ## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)。 ## ## MT_TEST_MODE=forest_render MT_FOREST_MAPS=[,] godot --path project ## bash script/forest_map_render_test.sh --help # 包内运行、墙钟超时、最终报告 ## ## 地图只来自 MT_FOREST_MAPS 或 MT_PLAYABLE_CONFIG 的 map_key(由 audit_playable_maps 的结果填写), ## 不在脚本里猜地图。每张图:生产 GameScene 装图 -> 机位(夹具确认 / 自动候选)-> 树怪经 ## net_world 的真实生成路径(entity_spawned -> MobView 工厂 -> 贴地)-> 记录 sample_height、 ## 怪物世界位置、ground offset、材质、实际动作路径 -> 固定机位截图。 ## ## 判定边界: ## - 平地(足迹内地形高差 <= FLAT_FOOTPRINT_M)上 wait 各采样帧都悬空/埋地超过 GROUND_GAP_M 才算失败; ## AABB 底部不是足部,坡地只记录数据交人工看脚/根部接触,不据此宣布足部 IK 完成。 ## - 自动候选机位、未确认机位、缺失的传送点、headless(无 Metal 截图)一律 BLOCKED,不当 PASS。 ## - 这里没有服务器刷新:选中/攻击/死亡/拾取的实网证据由 playable_live_test 单独记录。 ## ## 必测用例由 required_case_ids(maps) 决定(script/forest_map_render_test.sh 在启动前写同一份列表), ## Metal 截图的人工签核是发布清单里单独的 manual 项,不由本进程判定。 ## ## 退出码:单独运行 0 PASS / 1 FAIL / 2 BLOCKED;由 runner 启动(有 MT_PLAYABLE_RUN_ID)时写出报告即 0, ## 判定交给 runner。客户端只写 client-report.json、events 与 forest-map-evidence.json。 const GameScene = preload("res://game_scene.gd") const Fixtures = preload("res://gamescene_test.gd") const Viewpoints = preload("res://testing/forest_viewpoints.gd") const PlayableReport = preload("res://testing/playable_report.gd") ## root/npclist.txt 2301–2307 / 2311–2315;未由服务器确认前只是候选。 const CANDIDATE_RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] const WAIT_SAMPLES := [0.0, 0.25, 0.5, 0.75] const GRID_STEP_M := 4.0 const FOOTPRINT_HALF_M := 1.5 ## 平地判定:怪物包围盒足迹内地形最高/最低差。超过即交人工接触检查,不做自动悬空判定。 const FLAT_FOOTPRINT_M := 0.10 ## 平地 wait 姿态 AABB 底部与地形高度的允许差(米)。校准记录见 CALIBRATION。 const GROUND_GAP_M := 0.10 const CALIBRATION := "GROUND_GAP_M=0.10 m (2026-09-11, Metal, metin2_map_trent auto viewpoints): MobView grounds the bind-pose bottom via Metin2Model ground_offset; over 16 flat placements of the 12 candidate races the largest wait-loop AABB-bottom gap was 0.026 m (ent_trent, sinks slightly). 0.10 m keeps ~4x margin while still catching a missing/doubled ground offset (0.04-0.15 m per race). Changes need a recorded reason." const ROOT_GAP_M := 0.001 const MAIN_VID := 1000 const MOB_VID_BASE := 60000 const CHAR_TYPE_MONSTER := 2 const CAMERA_PITCH_DEG := 28.0 const DEFAULT_TIMEOUT_SECONDS := 900 var _out := "" var _report := PlayableReport.new() var _evidence := {} var _finished := false func _ready() -> void: call_deferred("run") func run() -> void: _out = OS.get_environment("MT_TEST_OUTPUT") if _out.is_empty(): _out = OS.get_environment("MT_RENDER_OUTPUT") if _out.is_empty(): _out = ProjectSettings.globalize_path("user://forest-map-%d" % Time.get_unix_time_from_system()) DirAccess.make_dir_recursive_absolute(_out) var timeout := int(OS.get_environment("MT_FOREST_TIMEOUT_SECONDS")) if OS.get_environment("MT_FOREST_TIMEOUT_SECONDS").is_valid_int() else DEFAULT_TIMEOUT_SECONDS get_tree().create_timer(timeout).timeout.connect(_on_watchdog) var headless := DisplayServer.get_name() == "headless" _evidence = {"schema_version": 1, "scope": "offline production GameScene + MobView placement; no server spawn, no live target/attack/pickup evidence", "headless": headless, "assets": AssetRoot.path(), "calibration": CALIBRATION, "thresholds": {"flat_footprint_m": FLAT_FOOTPRINT_M, "ground_gap_m": GROUND_GAP_M, "root_gap_m": ROOT_GAP_M}, "maps": [], "manual_review": ["Metal screenshots: tree monster silhouettes, materials, foot/root contact on slopes"], "live_evidence": "separate: playable_live_test per-map enter/select/attack/dead/pickup/leave"} var config := {} var config_path := OS.get_environment("MT_PLAYABLE_CONFIG") if not config_path.is_empty() and FileAccess.file_exists(config_path): var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path)) config = parsed if parsed is Dictionary else {} _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS")) var maps: Array[String] = [] for key in OS.get_environment("MT_FOREST_MAPS").split(",", false): if not key.strip_edges().is_empty(): maps.append(key.strip_edges()) if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty(): maps.append(String(config.map_key).strip_edges()) _report.set_required_cases(required_case_ids(maps)) var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS")) var races := parse_races(OS.get_environment("MT_FOREST_RACES")) var config_errors: Array[String] = [] if maps.is_empty(): config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json") var ids := {} for map_key in maps: if not is_safe_map_key(map_key): config_errors.append("map_key %s must be a relative asset path without '..'" % map_key) elif ids.has(map_key.get_file()): config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()]) ids[map_key.get_file()] = true if not vp_file.ok: config_errors.append_array(vp_file.errors) if not races.ok: config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums") if not config_errors.is_empty(): _report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors)) else: _report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps), "yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"]) get_tree().root.size = Vector2i(1280, 720) for map_key in maps: await _run_map(map_key, vp_file.data, races.races, headless) _finish() static func required_case_ids(maps: Array) -> Array[String]: var ids: Array[String] = ["MAP-FOREST-CONFIG"] for map_key in maps: var map_id := String(map_key).get_file() ids.append("MAP-FOREST-LOAD-" + map_id) for kind in Viewpoints.KINDS: ids.append("MAP-FOREST-VP-%s-%s" % [map_id, kind]) ids.append("MAP-FOREST-MOTION-" + map_id) return ids static func is_safe_map_key(map_key: String) -> bool: return not map_key.is_empty() and not map_key.begins_with("/") and not ("\\" in map_key) \ and not (".." in map_key.split("/")) static func parse_races(text: String) -> Dictionary: var out := {"ok": true, "races": []} for part in text.split(",", false): var value := part.strip_edges() if not value.is_valid_int() or int(value) <= 0: out.ok = false continue out.races.append(int(value)) return out func _on_watchdog() -> void: if _finished: return _report.add_failure("forest render watchdog: run did not finish (script error or hang)") _finish() func _finish() -> void: if _finished: return _finished = true var report_path := OS.get_environment("MT_TEST_REPORT") if report_path.is_empty(): report_path = _out.path_join("client-report.json") var final := _report.finish() _evidence["status"] = final.get("status", "FAIL") # 自动判定之外仍需人工看图;写进报告,避免把自动 PASS 读成视觉签核完成。 final["manual_signoff"] = {"required": true, "status": "PENDING", "evidence": "forest-*.png + forest-map-evidence.json", "scope": "tree monster silhouettes/materials, slope foot-root contact, dense-forest occlusion, dead poses"} _evidence["manual_signoff"] = final.manual_signoff var f := FileAccess.open(_out.path_join("forest-map-evidence.json"), FileAccess.WRITE) if f: f.store_string(JSON.stringify(_evidence, "\t")) f.close() var written := _report.write(report_path) var status := String(final.get("status", "FAIL")) print("FOREST MAP RENDER: status=%s failures=%d blocked=%d output=%s" % [status, final.get("failures", []).size(), final.get("blocked", []).size(), _out]) for line in final.get("failures", []): printerr("FOREST MAP FAIL: " + String(line)) var code := 0 if status == "PASS" else (1 if status == "FAIL" else 2) if not written: printerr("FOREST MAP RENDER: cannot write client report") code = 1 elif not OS.get_environment("MT_PLAYABLE_RUN_ID").is_empty(): code = 0 # 由父 runner 在真实退出后判定;非零只表示客户端没能写出报告。 # 被释放的 AudioStreamPlayer3D 的 playback 由音频线程异步移除;立即 quit 会报 ObjectDB 泄漏。 get_tree().create_timer(0.3, true, false, true).timeout.connect(func() -> void: get_tree().quit(code)) func _run_map(map_key: String, vp_data: Dictionary, races_override: Array, headless: bool) -> void: var map_id := map_key.get_file() var map_ev := {"map_key": map_key, "viewpoints": []} _evidence.maps.append(map_ev) var bounds := {} var probe := GameScene.new() for name in ["setting.txt", "Setting.txt"]: var p := AssetRoot.path().path_join(map_key).path_join(name) if FileAccess.file_exists(p): bounds = probe._map_bounds(p) break probe.free() if bounds.is_empty(): _report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "Setting.txt with BasePosition/MapSize not found for %s" % map_key) return var plan := Viewpoints.plan(vp_data, map_key, bounds) if not plan.errors.is_empty(): _report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "viewpoint fixture invalid: " + "; ".join(plan.errors)) return var size_m := Vector2(bounds.size) * Viewpoints.MAP_CELL_CM * 0.01 var races: Array = plan.races.duplicate() var race_source := "fixture" if not races_override.is_empty(): races = races_override.duplicate() race_source = "MT_FOREST_RACES" if races.is_empty(): races = CANDIDATE_RACES.duplicate() race_source = "candidate_only" map_ev["bounds"] = {"base_cm": [bounds.base.x, bounds.base.y], "size_tiles": [bounds.size.x, bounds.size.y]} map_ev["races"] = races map_ev["race_source"] = race_source var client := Fixtures.FakeClient.new() client.name = "ForestFakeClient" get_tree().root.add_child(client) var centre_cm: Vector2 = bounds.base + size_m * 50.0 client.spawn(MAIN_VID, "ForestRender", _net_pos(centre_cm), true) var scene := GameScene.new() scene.name = "ForestGameScene" get_tree().root.add_child(scene) var started := Time.get_ticks_msec() await scene.setup(client, AssetRoot.path(), map_key) var world: Node = scene.world while world and int(world.call("get_load_report").get("stream_queue", 0)) > 0: await get_tree().process_frame var load_report: Dictionary = world.call("get_load_report") if world else {} map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path} var load_errors: Array[String] = [] if not scene._map_loaded(): load_errors.append("map did not load") if scene.map_path != map_key: load_errors.append("GameScene resolved %s instead of %s" % [scene.map_path, map_key]) for key in ["chunks_failed", "objects_missing_model"]: if int(load_report.get(key, 0)) > 0: load_errors.append("%s=%d" % [key, int(load_report.get(key, 0))]) if scene.net_world == null or scene.proto == null: load_errors.append("net_world/mob_proto not ready; monsters would fall back to capsules") _report.record("map_loaded", "forest_render", "MAP-FOREST-LOAD-" + map_id, 0, MAIN_VID, 0, {"map_key": map_key, "elapsed_ms": map_ev.load.ms, "count": int(load_report.get("chunks_built", 0)), "reason_code": "ok" if load_errors.is_empty() else "load_failed"}) _report.add_case("MAP-FOREST-LOAD-" + map_id, "FAIL" if not load_errors.is_empty() else "PASS", "; ".join(load_errors) if not load_errors.is_empty() else "chunks=%d objects=%d trees=%d" % [ int(load_report.get("chunks_built", 0)), int(load_report.get("objects_placed", 0)), int(load_report.get("trees_placed", 0))]) if not load_errors.is_empty(): await _teardown(scene, client) return var viewpoints: Array = plan.viewpoints.duplicate() var auto_kinds: Array = plan.missing_kinds.filter(func(k: String) -> bool: return k in Viewpoints.AUTO_KINDS) if not auto_kinds.is_empty(): var trees := _tree_positions(world) map_ev["tree_trunks_found"] = trees.size() if not trees.is_empty(): var r := Rect2(trees[0], Vector2.ZERO) for t in trees: r = r.expand(t) map_ev["tree_trunk_bounds_m"] = [r.position.x, r.position.y, r.end.x, r.end.y] # headless 的 RenderingServerDummy 不保存 MultiMesh 实例数据,读回全是单位变换。 if trees.size() > 1 and r.size.is_zero_approx(): map_ev["tree_positions"] = "unavailable: MultiMesh instance transforms are not readable from the headless rendering server" trees = PackedVector2Array() var candidates := Viewpoints.pick_candidates(_terrain_samples(world, size_m), trees, size_m, auto_kinds) for c: Dictionary in candidates: viewpoints.append({"id": "auto-" + String(c.kind), "kind": c.kind, "status": "auto_candidate", "source": "auto", "confirmed_by": "", "camera_yaw_deg": Viewpoints.DEFAULT_YAW_DEG, "local_m": c.p, "server_cm": Viewpoints.to_server_cm(bounds, c.p), "selection": {"range_m": c.range_m, "slope_ratio": c.slope_ratio, "tree_density": c.density}}) var motion_errors: Array[String] = [] var fallbacks: Array = [] var by_kind := {} for vp: Dictionary in viewpoints: var vp_ev := await _run_viewpoint(scene, client, world, vp, races, map_id, headless) map_ev.viewpoints.append(vp_ev) for line: String in vp_ev.motion_errors: if not (line in motion_errors): motion_errors.append(line) fallbacks.append_array(vp_ev.fallbacks) if not by_kind.has(vp.kind): by_kind[vp.kind] = [] by_kind[vp.kind].append(vp_ev) _report.record("viewpoint", "forest_render", "MAP-FOREST-VP-%s-%s" % [map_id, vp.kind], 0, 0, 0, {"map_key": map_key, "state": vp.kind, "reason_code": vp.status, "count": vp_ev.mobs.size(), "evidence": vp_ev.screenshots, "selected": vp.id}) for kind in Viewpoints.KINDS: _add_kind_case(map_id, kind, by_kind.get(kind, []), map_ev, headless) map_ev["reference_fallbacks"] = fallbacks if viewpoints.is_empty(): _report.add_case("MAP-FOREST-MOTION-" + map_id, "BLOCKED", "no viewpoint to place tree monsters") else: _report.add_case("MAP-FOREST-MOTION-" + map_id, "FAIL" if not motion_errors.is_empty() else "PASS", "; ".join(motion_errors) if not motion_errors.is_empty() else "wait/run/dead via net_world; %d reference keep-current-motion fallbacks need live confirmation" % fallbacks.size()) await _teardown(scene, client) ## 每种机位一个必测用例:任一机位断言失败即 FAIL;只有“经确认 + Metal 截图 + 全部断言通过”才 PASS。 func _add_kind_case(map_id: String, kind: String, results: Array, map_ev: Dictionary, headless: bool) -> void: var case_id := "MAP-FOREST-VP-%s-%s" % [map_id, kind] var errors: Array[String] = [] var screenshots: Array = [] var confirmed: Array[String] = [] var unconfirmed: Array[String] = [] for r: Dictionary in results: screenshots.append_array(r.screenshots) for e: String in r.errors: errors.append("%s: %s" % [r.id, e]) if r.status == "confirmed": confirmed.append(String(r.id)) else: unconfirmed.append("%s(%s @ server_cm %s)" % [r.id, r.status, r.server_cm]) if not errors.is_empty(): _report.add_case(case_id, "FAIL", "; ".join(errors), screenshots) elif results.is_empty(): var why := "no confirmed viewpoint and no usable terrain candidate" if kind == "warp": why = "warp points cannot be derived offline; add a confirmed viewpoint" elif kind == "dense" and map_ev.has("tree_positions"): why = "no confirmed viewpoint; tree density needs a Metal run (%s)" % map_ev.tree_positions _report.add_case(case_id, "BLOCKED", why) elif headless: _report.add_case(case_id, "BLOCKED", "headless run has no Metal screenshot", screenshots) elif confirmed.is_empty() or not unconfirmed.is_empty(): _report.add_case(case_id, "BLOCKED", "viewpoints not confirmed by the test environment owner: %s" % ", ".join(unconfirmed), screenshots) else: _report.add_case(case_id, "PASS", "confirmed %s: %s" % [kind, ", ".join(confirmed)], screenshots) func _run_viewpoint(scene: Node, client: Node, world: Node, vp: Dictionary, races: Array, map_id: String, headless: bool) -> Dictionary: var ev := {"id": vp.id, "kind": vp.kind, "status": vp.status, "source": vp.source, "server_cm": [vp.server_cm.x, vp.server_cm.y], "local_m": [vp.local_m.x, vp.local_m.y], "camera_yaw_deg": vp.camera_yaw_deg, "selection": vp.get("selection", {}), "mobs": [], "screenshots": [], "errors": [], "motion_errors": [], "fallbacks": []} var centre: Vector2 = vp.local_m var yaw := deg_to_rad(float(vp.camera_yaw_deg)) var to_camera := Vector3(sin(yaw), 0.0, cos(yaw)) var right := Vector3.UP.cross(to_camera).normalized() var base: Vector2 = vp.server_cm - vp.local_m * 100.0 # 主角放到怪群侧面,避免挡住机位。 var player_local := centre + Vector2(right.x, right.z) * 12.0 client.ents[MAIN_VID].pos = _net_pos(base + player_local * 100.0) scene._place_player_at_net_pos(client.ents[MAIN_VID].pos) var vids: Array[int] = [] for i in races.size(): var vid := MOB_VID_BASE + i vids.append(vid) client.ents[vid] = {"vid": vid, "name": "Race%d" % races[i], "race": int(races[i]), "ch_type": CHAR_TYPE_MONSTER, "pos": _net_pos(vp.server_cm), "is_main": false, "func": 0, "moving": false, "angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false} client.entity_spawned.emit(client.ents[vid]) await get_tree().process_frame # 按最大足迹排成面向相机的网格,再让 net_world 按新 pos 贴地。 var spacing := 3.5 for vid in vids: var node: Node3D = scene.net_world.node_for(vid) if node != null and node.has_method("resolve_motion") and node.model: # get_visual_aabb 是模型本地单位(未乘 unit_scale),先变到世界米。 var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) spacing = maxf(spacing, maxf(box.size.x, box.size.z) + 1.0) var cols := int(ceil(sqrt(float(races.size())))) var rows := int(ceil(float(races.size()) / float(cols))) for i in vids.size(): var offset := right * (float(i % cols) - (cols - 1) * 0.5) * spacing \ + to_camera * (float(i / cols) - (rows - 1) * 0.5) * spacing client.ents[vids[i]].pos = _net_pos(base + (centre + Vector2(offset.x, offset.z)) * 100.0) for _i in 3: await get_tree().process_frame var cam := Camera3D.new() cam.name = "ForestFixedCamera" cam.fov = 50.0 scene.add_child(cam) cam.current = true var top := 0.0 for i in vids.size(): var node: Node3D = scene.net_world.node_for(vids[i]) var mob := await _measure_mob(node, int(races[i]), vids[i], world, ev) ev.mobs.append(mob) top = maxf(top, float(mob.get("height_m", 0.0))) var ground := float(world.call("sample_height", centre.x, centre.y)) var look := Vector3(centre.x, ground + maxf(1.0, top * 0.5), centre.y) var distance := maxf(12.0, spacing * float(maxi(cols, rows)) * 1.15) var pitch := deg_to_rad(CAMERA_PITCH_DEG) cam.position = look + (to_camera * cos(pitch) + Vector3.UP * sin(pitch)) * distance cam.position.y = maxf(cam.position.y, float(world.call("sample_height", cam.position.x, cam.position.z)) + 1.5) cam.look_at(look, Vector3.UP) ev["camera"] = {"position": [cam.position.x, cam.position.y, cam.position.z], "look_at": [look.x, look.y, look.z], "fov": cam.fov, "distance_m": distance, "grid_spacing_m": spacing} for i in vids.size(): var node: Node3D = scene.net_world.node_for(vids[i]) if node == null: continue var anchor := node.global_position + Vector3.UP * 0.5 var screen := get_viewport().get_visible_rect().grow(-8.0) if cam.is_position_behind(anchor) or not screen.has_point(cam.unproject_position(anchor)): ev.errors.append("race %d is outside the fixed camera frame" % races[i]) if not headless: await _screenshot(cam, "forest-%s-%s-wait.png" % [map_id, vp.id], ev) # 生产路径动作:移动(资源无 RUN/WALK 时按旧客户端保留当前动作)与死亡。 for i in vids.size(): var node: Node3D = scene.net_world.node_for(vids[i]) if node != null and node.has_method("resolve_motion"): node.anim.set_process(true) client.ents[vids[i]].moving = true for _i in 2: await get_tree().process_frame for i in vids.size(): _check_motion(scene.net_world.node_for(vids[i]), int(races[i]), "run", ev) client.ents[vids[i]].moving = false client.ents[vids[i]].dead = true client.entity_dead.emit(vids[i]) await get_tree().create_timer(0.4).timeout for i in vids.size(): var node: Node3D = scene.net_world.node_for(vids[i]) _check_motion(node, int(races[i]), "dead", ev) if node != null and node.has_method("resolve_motion") and String(node.last_motion_resolution().path) != "": if absf(node.rotation.x) > 0.01 or absf(node.rotation.z) > 0.01: ev.motion_errors.append("race %d dead motion is tilted by the scene (rotation.x=%.2f); reference Die() only plays DEAD" % [races[i], node.rotation.x]) node.anim.set_process(false) node.anim.call("set_time", maxf(0.0, float(node.anim.call("get_duration")) - 1.0 / 60.0)) var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) var h := float(world.call("sample_height", node.global_position.x, node.global_position.z)) for mob: Dictionary in ev.mobs: if int(mob.vid) == vids[i]: mob["dead_end_bottom_gap_m"] = box.position.y - h if not headless: await _screenshot(cam, "forest-%s-%s-dead.png" % [map_id, vp.id], ev) for vid in vids: client.ents.erase(vid) client.entity_despawned.emit(vid) cam.queue_free() for _i in 2: await get_tree().process_frame return ev func _measure_mob(node: Node3D, race: int, vid: int, world: Node, ev: Dictionary) -> Dictionary: var out := {"race": race, "vid": vid} if node == null or not node.has_method("resolve_motion") or node.model == null or node.anim == null: ev.errors.append("race %d spawned without a MobView model (placeholder capsule)" % race) out["model"] = false return out out["model"] = true var anim: Node = node.anim anim.set("blend_time", 0.0) anim.set_process(false) var root := node.global_position var h_root := float(world.call("sample_height", root.x, root.z)) out["world_position"] = [root.x, root.y, root.z] out["sample_height_m"] = h_root out["root_gap_m"] = root.y - h_root out["ground_offset_m"] = float(node.model.call("get_ground_offset")) out["model_local_y_m"] = node.model.position.y out["wait_resolution"] = node.last_motion_resolution() out["wait_motion_path"] = String(anim.get("anim_path")) out["materials"] = _material_report(node.model) if absf(root.y - h_root) > ROOT_GAP_M: ev.errors.append("race %d root is %.3fm off terrain; net_world grounding broken" % [race, root.y - h_root]) if String(out.wait_motion_path).is_empty(): ev.motion_errors.append("race %d has no wait motion after spawn" % race) if int(out.materials.surfaces) == 0 or int(out.materials.textured) < int(out.materials.surfaces) or int(out.materials.invalid) > 0: ev.errors.append("race %d materials: %s" % [race, out.materials]) var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) var half := Vector2(maxf(0.3, box.size.x * 0.5), maxf(0.3, box.size.z * 0.5)) var c := Vector2(box.get_center().x, box.get_center().z) var heights: Array[float] = [] for d in [Vector2.ZERO, Vector2(half.x, half.y), Vector2(-half.x, half.y), Vector2(half.x, -half.y), Vector2(-half.x, -half.y)]: heights.append(float(world.call("sample_height", c.x + d.x, c.y + d.y))) var footprint_range: float = heights.max() - heights.min() out["footprint_range_m"] = footprint_range out["height_m"] = box.size.y var samples: Array = [] var off := 0 var duration := float(anim.call("get_duration")) for f in WAIT_SAMPLES: anim.call("set_time", clampf(duration * f, 0.0, maxf(0.0, duration - 1.0 / 60.0))) var b: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) var gap := b.position.y - h_root samples.append({"fraction": f, "aabb_bottom_gap_m": gap}) if not b.position.is_finite() or not b.size.is_finite(): ev.errors.append("race %d wait sample %.2f has non-finite bounds" % [race, f]) if absf(gap) > GROUND_GAP_M: off += 1 out["wait_samples"] = samples anim.call("set_time", clampf(duration * 0.5, 0.0, maxf(0.0, duration - 1.0 / 60.0))) if footprint_range <= FLAT_FOOTPRINT_M: out["contact_check"] = "flat_auto" if off == WAIT_SAMPLES.size(): ev.errors.append("race %d floats/buries on flat ground: every wait sample exceeds %.2fm (%s)" % [race, GROUND_GAP_M, samples]) else: out["contact_check"] = "slope_manual" return out func _check_motion(node: Node3D, race: int, state: String, ev: Dictionary) -> void: if node == null or not node.has_method("resolve_motion"): return var res: Dictionary = node.last_motion_resolution() var path := String(node.anim.get("anim_path")) if String(res.get("requested_state", "")) != state: ev.motion_errors.append("race %d: net_world did not request %s (last=%s)" % [race, state, res.get("requested_state", "")]) return if String(res.path).is_empty(): if state == "run" and String(res.fallback_reason) == "reference_keep_current_motion" and not path.is_empty(): ev.fallbacks.append({"race": race, "state": state, "kept_motion_path": path, "needs_live_confirmation": true}) else: ev.motion_errors.append("race %d %s has no motion and is not a reference keep-current fallback" % [race, state]) return if path != String(res.path): ev.motion_errors.append("race %d %s resolved %s but plays %s" % [race, state, res.path, path]) if state == "dead" and (bool(node.anim.get("loop")) or not String(res.motion).contains("DEAD")): ev.motion_errors.append("race %d dead plays %s loop=%s" % [race, res.motion, node.anim.get("loop")]) func _screenshot(cam: Camera3D, file_name: String, ev: Dictionary) -> void: cam.current = true # 证据图只拍 3D 场景:HUD 与血条层会让“非均匀”检查在场景全坏时仍然通过。 var hidden: Array[CanvasLayer] = [] for layer in get_tree().root.find_children("*", "CanvasLayer", true, false): if (layer as CanvasLayer).visible: (layer as CanvasLayer).visible = false hidden.append(layer) for _i in 3: await get_tree().process_frame await RenderingServer.frame_post_draw var image := get_tree().root.get_texture().get_image() for layer in hidden: if is_instance_valid(layer): layer.visible = true if image == null or image.is_empty() or not _has_detail(image): ev.errors.append("screenshot %s is empty or uniform" % file_name) return if image.save_png(_out.path_join(file_name)) != OK: ev.errors.append("screenshot %s could not be saved" % file_name) return ev.screenshots.append(file_name) func _has_detail(image: Image) -> bool: var first := image.get_pixel(0, 0) var differing := 0 var total := 0 for y in range(0, image.get_height(), 16): for x in range(0, image.get_width(), 16): total += 1 var p := image.get_pixel(x, y) if absf(p.r - first.r) + absf(p.g - first.g) + absf(p.b - first.b) > 0.05: differing += 1 return total > 0 and float(differing) / float(total) > 0.1 func _material_report(model: Node) -> Dictionary: var out := {"surfaces": 0, "textured": 0, "two_sided": 0, "invalid": 0} for node in model.find_children("*", "MeshInstance3D", true, false): var instance := node as MeshInstance3D if instance.mesh == null: continue for surface in instance.mesh.get_surface_count(): out.surfaces += 1 var material := instance.get_active_material(surface) if material is ShaderMaterial and (material as ShaderMaterial).shader != null: var sm := material as ShaderMaterial if bool(sm.get_shader_parameter("use_texture")) and sm.get_shader_parameter("albedo_tex") != null: out.textured += 1 if sm.shader.code.contains("cull_disabled"): out.two_sided += 1 elif material is BaseMaterial3D: if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null: out.textured += 1 if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED: out.two_sided += 1 else: out.invalid += 1 return out func _terrain_samples(world: Node, size_m: Vector2) -> Array: var out: Array = [] var x := GRID_STEP_M * 0.5 while x < size_m.x: var z := GRID_STEP_M * 0.5 while z < size_m.y: var lo := INF var hi := -INF for d in [Vector2.ZERO, Vector2(FOOTPRINT_HALF_M, 0), Vector2(-FOOTPRINT_HALF_M, 0), Vector2(0, FOOTPRINT_HALF_M), Vector2(0, -FOOTPRINT_HALF_M)]: var h := float(world.call("sample_height", x + d.x, z + d.y)) lo = minf(lo, h) hi = maxf(hi, h) out.append({"p": Vector2(x, z), "range_m": hi - lo, "span_m": FOOTPRINT_HALF_M * 2.0, "blocked": bool(world.call("is_blocked", x, z))}) z += GRID_STEP_M x += GRID_STEP_M return out func _tree_positions(world: Node) -> PackedVector2Array: var out := PackedVector2Array() for node in world.find_children("Trees_*", "MultiMeshInstance3D", true, false): var mm := (node as MultiMeshInstance3D).multimesh if mm == null: continue for i in mm.instance_count: var origin := ((node as MultiMeshInstance3D).global_transform * mm.get_instance_transform(i)).origin out.append(Vector2(origin.x, origin.z)) return out func _net_pos(server_cm: Vector2) -> Vector3: return Vector3(server_cm.x * 0.01, 0.0, -server_cm.y * 0.01) func _teardown(scene: Node, client: Node) -> void: scene.queue_free() client.queue_free() for _i in 3: await get_tree().process_frame