- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
597 lines
29 KiB
GDScript
597 lines
29 KiB
GDScript
extends Node
|
||
|
||
## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)。
|
||
##
|
||
## MT_TEST_MODE=forest_render MT_FOREST_MAPS=<map_key>[,<map_key>] 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
|