# package_render_test —— 在**导出包内部**跑的渲染自检。 # # MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc # # 存在的理由:导出模板不接受 `--script`,所以 model_render_test.gd / # gamescene_visual_test.gd 这类 `extends SceneTree` 的离线回归**只能在编辑器里跑**。 # 编辑器全绿而实机进游戏是「灰紫虚空 + 蓝胶囊」时,两边差异无从定位——包内没有 # 任何不依赖服务器的入口。本脚本就是那个入口:由 client_main.gd 在 # MT_TEST_MODE=render 时挂到主场景下,走真实 GameScene + 真实 Metin2World + # 真实 PlayerView,不连服务器、不写账号,跑完把报告和截图落到 user:// 并退出。 # # 覆盖范围:包内的资源解析、GDExtension 注册、地图装载、角色装配、落地高度。 # **不覆盖**:网络驱动的进场时序、40250 像素比对、性能。 extends Node const GameScene = preload("res://game_scene.gd") const Fixtures = preload("res://gamescene_test.gd") const MAP := "OutdoorA1/metin2_map_a1" # 与 gamescene_visual_test.gd 同一组坐标和阈值,两边结论才可比。 const SERVER_CM := Vector2(454600, 934000) const FEET_GAP_MAX := 0.03 const SILHOUETTE_MIN := 3000 var _fail: Array[String] = [] var _out := "" func _ready() -> void: _out = OS.get_environment("MT_RENDER_OUTPUT") if _out.is_empty(): _out = ProjectSettings.globalize_path("user://render-check") DirAccess.make_dir_recursive_absolute(_out) call_deferred("_run") func _run() -> void: var report := {"env": _env(), "scope": "in-package offline check: real GameScene + Metin2World + PlayerView, no network"} # 1) 扩展必须注册 —— 少了它 GameScene 会退回蓝胶囊 + 无地形灰底, # 而这两件事在画面上和「资源没找到」长得一模一样,必须先分开。 for cls in ["Metin2Model", "Metin2World", "Metin2AnimPlayer"]: if not ClassDB.class_exists(cls): _fail.append("GDExtension 类未注册:%s" % cls) # 2) 资源根必须落在包内(或 MT_ASSETS 指定处)且真的有地图和角色 var root: String = report["env"]["asset_root"] for probe in [MAP, "PC/ymir work/pc/warrior", "asset_index.txt"]: var p := root.path_join(probe) if not (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)): _fail.append("资源缺失:%s" % p) if _fail.is_empty(): report["scene"] = await _build_and_measure() else: report["scene"] = {"skipped": "前置检查已失败,不再装场景"} report["passed"] = _fail.is_empty() report["failures"] = _fail var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE) f.store_string(JSON.stringify(report, "\t")) f.close() print("PKGRENDER: ", _out) print(JSON.stringify(report)) for m in _fail: printerr("PKGRENDER FAIL: ", m) print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL") get_tree().quit(0 if _fail.is_empty() else 1) # 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。 func _env() -> Dictionary: var cands := {} for c in _asset_candidates(): cands[c] = DirAccess.dir_exists_absolute(c) return { "engine": Engine.get_version_info()["string"], "executable": OS.get_executable_path(), "res_globalized": ProjectSettings.globalize_path("res://"), "user_dir": OS.get_user_data_dir(), "features": {"template": OS.has_feature("template"), "debug": OS.has_feature("debug"), "release": OS.has_feature("release"), "editor": OS.has_feature("editor")}, "MT_ASSETS": OS.get_environment("MT_ASSETS"), "MT_PROTOCOL": OS.get_environment("MT_PROTOCOL"), "asset_candidates": cands, "asset_root": AssetRoot.path(), "asset_root_available": AssetRoot.available(), "classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"), "Metin2World": ClassDB.class_exists("Metin2World"), "Metin2AnimPlayer": ClassDB.class_exists("Metin2AnimPlayer")}, "rendering_driver": DisplayServer.get_name(), "viewport": str(get_window().size), } # AssetRoot._candidates() 是私有的;这里复述一遍只为把每个候选的命中情况写进报告。 func _asset_candidates() -> Array: var out: Array = [] var env := OS.get_environment("MT_ASSETS") if env != "": out.append(env) if FileAccess.file_exists("res://assets/asset_index.txt"): out.append("res://assets") out.append(ProjectSettings.globalize_path("res://../assets")) var exe := OS.get_executable_path() if exe != "": var d := exe.get_base_dir() out.append(d.path_join("../Resources/assets").simplify_path()) out.append(d.path_join("../../../assets").simplify_path()) out.append(d.path_join("assets")) return out func _build_and_measure() -> Dictionary: var fc := Fixtures.FakeClient.new() add_child(fc) var scene := GameScene.new() add_child(scene) await scene.setup(fc, AssetRoot.path(), MAP) fc.main = 1000 fc.ents[1000] = {"vid": 1000, "name": "PkgRenderTest", "race": 0, "pos": Vector3(SERVER_CM.x * 0.01, 0, -SERVER_CM.y * 0.01), "pos_cm": SERVER_CM, "is_main": true, "angle_deg": 0.0, "func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false} for i in 30: await get_tree().process_frame var expected := MapCoord.to_world(fc.ents[1000].pos) expected.y = scene.world.sample_height(expected.x, expected.z) if scene.world else 0.0 var surfaces := 0 for mi in scene.player.find_children("*", "MeshInstance3D", true, false): if mi.mesh: surfaces += mi.mesh.get_surface_count() if scene._model_built and scene.player.anim: scene.player.anim.set_time(0.5) scene.player.anim.set_process(false) await get_tree().process_frame var feet_y := INF for mi in scene.player.find_children("*", "MeshInstance3D", true, false): if mi.mesh: feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y) var feet_gap := feet_y - expected.y if not scene._model_built: _fail.append("主角模型没装出来(仍是 _make_placeholder_player 的蓝胶囊)") if not scene._map_loaded(): _fail.append("地图没装出来(灰底虚空)") if surfaces == 0: _fail.append("玩家节点下 0 个 surface") if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX: _fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX]) var shot := "SKIPPED_HEADLESS" if DisplayServer.get_name() != "headless": scene.cam.snap_to_target() await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw var img := get_window().get_texture().get_image() shot = "game.png" if img.save_png(_out.path_join("game.png")) == OK else "SAVE_FAILED" var d := {"map": scene.map_path, "model_built": scene._model_built, "map_loaded": scene._map_loaded(), "surfaces": surfaces, "world_position": str(scene.player.position), "expected": str(expected), "feet_y": feet_y, "feet_gap": feet_gap, "feet_gap_max": FEET_GAP_MAX, "map_report": scene.world.get_load_report() if scene.world else {}, "camera_transform": str(scene.cam.global_transform), "screenshot": shot} scene.queue_free() fc.queue_free() await get_tree().process_frame return d