Files
mtgodot-poc/project/forest_map_render_test.gd
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 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
2026-09-11 22:06:48 +09:00

597 lines
29 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 23012307 / 23112315;未由服务器确认前只是候选。
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