199 lines
8.6 KiB
GDScript
199 lines
8.6 KiB
GDScript
# 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["effects"] = await _check_effect_resources(root)
|
|
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 _check_effect_resources(assets: String) -> Dictionary:
|
|
var dds_path := assets.path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds")
|
|
var decoded := preload("res://ui/ui_assets.gd").load_dds_image(dds_path)
|
|
var dds_ok := decoded != null and not decoded.is_empty()
|
|
if not dds_ok:
|
|
_fail.append("包内 16 位技能 DDS 解码失败")
|
|
var mse_path := assets.path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse")
|
|
var spec := preload("res://fx/mse.gd").new().parse_file(mse_path)
|
|
var effect := preload("res://fx/effect_player.gd").new()
|
|
effect.build(spec, assets)
|
|
add_child(effect)
|
|
# ParticleProcessMaterial shader variants are compiled on a deferred frame.
|
|
# Exercise a real lifecycle instead of freeing uninitialized GPU emitters.
|
|
await get_tree().process_frame
|
|
if DisplayServer.get_name() != "headless":
|
|
await RenderingServer.frame_post_draw
|
|
var special_materials := 0
|
|
for emitter in effect._emitters:
|
|
if emitter.material_override is ShaderMaterial:
|
|
special_materials += 1
|
|
if special_materials == 0:
|
|
_fail.append("包内真实技能未装配专用颜色材质")
|
|
effect.free()
|
|
await get_tree().process_frame
|
|
return {"dds_decoded": dds_ok, "special_materials": special_materials,
|
|
"dds_sha256": FileAccess.get_sha256(dds_path), "mse_sha256": FileAccess.get_sha256(mse_path),
|
|
"scope": "bundled DDS decode and real MSE material assembly; not skill pixels"}
|
|
|
|
# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。
|
|
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 scene.world:
|
|
var trees: Dictionary = scene.world.get_load_report()
|
|
if int(trees.get("native_trees_placed", 0)) <= 0 or int(trees.get("proxy_trees_placed", -1)) != 0:
|
|
_fail.append("真实树木缺失或仍有代理回退:%s" % trees)
|
|
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
|