fix(rendering): checkpoint main-character recovery and visual regression work
Handle late main-character data, guarded model construction, bounded retries and map correction. Validate player model sources and add lifecycle and real-asset visual regressions. Include pending material and character-selection changes, updated A1 screenshot, and the detailed rendering repair plan. Hair occlusion, full UI parity and final macOS package acceptance remain unfinished.
This commit is contained in:
@@ -29,6 +29,9 @@ func _run() -> void:
|
||||
quit(1)
|
||||
return
|
||||
print("Selected armor: ", screen._pv.model.get("gr2_path"))
|
||||
print("Hair skin: ", screen._pv.model.get("hair_skin"))
|
||||
var ua = preload("res://ui/ui_assets.gd")
|
||||
ua.load_dds_image(String(screen._pv.model.get("hair_skin"))).save_png("/tmp/metin2-hair.png")
|
||||
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
|
||||
for surface in mi.mesh.get_surface_count():
|
||||
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
|
||||
|
||||
+101
-14
@@ -120,6 +120,17 @@ var weather: Node3D # Weather
|
||||
var _assets := ""
|
||||
var map_path := ""
|
||||
var _model_built := false
|
||||
var _main_model_attempted := false
|
||||
var _main_sync_ready := false
|
||||
var _main_loading := false
|
||||
var _main_generation := 0
|
||||
var _main_view_key := ""
|
||||
var _main_attempts := 0
|
||||
var _main_retry_at := 0
|
||||
var _main_placed_vid := 0
|
||||
var _main_map_vid := 0
|
||||
const MAIN_MODEL_MAX_ATTEMPTS := 3
|
||||
const MAIN_MODEL_RETRY_MS := 1000
|
||||
var _audio: Node
|
||||
var _bgm_director: Node
|
||||
var _mount: Node3D
|
||||
@@ -548,6 +559,7 @@ func setup(m2client: Node, assets_root: String,
|
||||
_setup_mobile_ui()
|
||||
|
||||
client.entity_main_set.connect(_on_main_set)
|
||||
_main_sync_ready = true
|
||||
if client.get_main_vid() != 0: # 重连 / 已在局内
|
||||
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
|
||||
# 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
|
||||
@@ -639,6 +651,13 @@ func _lifecycle_node() -> Node:
|
||||
return null
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
# `entered_game` is emitted by the phase transition, while the first
|
||||
# GC_MAIN_CHARACTER / EntityStore change is drained later in the same or a
|
||||
# following frame. The setup-time catch-up below cannot see that packet in
|
||||
# all server timings, so keep the event path and add this idempotent fallback.
|
||||
# Without it the local player stays as the blue CapsuleMesh forever and the
|
||||
# camera follows an ungrounded placeholder, which makes the map look empty.
|
||||
_sync_main_character()
|
||||
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
||||
# node keeps its listener at the origin and rebases active 3D instances.
|
||||
if _audio == null or player == null or not is_instance_valid(player):
|
||||
@@ -853,6 +872,14 @@ func _on_warp(pos: Vector3, same_server: bool) -> void:
|
||||
_place_player_at_net_pos(pos)
|
||||
|
||||
func _on_world_reset() -> void:
|
||||
_main_generation += 1
|
||||
_main_view_key = ""
|
||||
_main_attempts = 0
|
||||
_main_retry_at = 0
|
||||
_main_placed_vid = 0
|
||||
_main_map_vid = 0
|
||||
_model_built = false
|
||||
_main_model_attempted = false
|
||||
if net_world and net_world.has_method("clear_for_map_change"):
|
||||
net_world.clear_for_map_change()
|
||||
if ground_items and ground_items.has_method("clear_for_map_change"):
|
||||
@@ -1369,6 +1396,10 @@ func _build_lighting() -> void:
|
||||
_env = e
|
||||
|
||||
func _on_main_set(vid: int) -> void:
|
||||
if not _main_sync_ready or _main_loading or vid <= 0 or client == null:
|
||||
return
|
||||
if vid != int(client.get_main_vid()):
|
||||
return # A queued event may belong to the previous character.
|
||||
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
|
||||
# as local before building its view so NetWorld removes (and never recreates)
|
||||
# the generic remote-player capsule for our own character.
|
||||
@@ -1379,11 +1410,45 @@ func _on_main_set(vid: int) -> void:
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty() or player == null:
|
||||
return
|
||||
var net_pos: Variant = e.get("pos", null)
|
||||
if not net_pos is Vector3 or not net_pos.is_finite():
|
||||
return
|
||||
# setup can only choose a map from data already received. Resolve again
|
||||
# once the late authoritative position is available, before placing/viewing.
|
||||
if _main_map_vid != vid:
|
||||
_main_map_vid = vid
|
||||
if _assets != "":
|
||||
var next_map := _resolve_map_path(map_path, Vector2(net_pos.x * 100.0, -net_pos.z * 100.0))
|
||||
if next_map != map_path:
|
||||
_main_loading = true
|
||||
_reload_map_for_warp(net_pos)
|
||||
_main_loading = false
|
||||
var key := "%d:%d:%s" % [vid, int(e.get("race", -1)), _assets]
|
||||
if key != _main_view_key:
|
||||
_main_view_key = key
|
||||
_main_attempts = 0
|
||||
_main_retry_at = 0
|
||||
_main_model_attempted = false
|
||||
_model_built = false
|
||||
# 首次拿到 race -> 换成真 Metin2Model
|
||||
if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0:
|
||||
var pv := PlayerView.new()
|
||||
if not _model_built and _assets != "" and int(e.get("race", -1)) in range(8) \
|
||||
and _main_attempts < MAIN_MODEL_MAX_ATTEMPTS and Time.get_ticks_msec() >= _main_retry_at:
|
||||
_main_model_attempted = true
|
||||
_main_attempts += 1
|
||||
_main_loading = true
|
||||
var generation := _main_generation
|
||||
var pv = _create_main_view()
|
||||
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
||||
if pv.build(_assets, int(e.get("race", 0)), pump):
|
||||
var built: bool = pv.build(_assets, int(e.get("race", 0)), pump)
|
||||
_main_loading = false
|
||||
# build() polls the network. World reset / new main data may arrive
|
||||
# inside it; do not publish a view belonging to an obsolete generation.
|
||||
if generation != _main_generation or is_queued_for_deletion() \
|
||||
or vid != int(client.get_main_vid()) \
|
||||
or int(client.get_entity(vid).get("race", -1)) != int(e.get("race", -1)):
|
||||
pv.free()
|
||||
return
|
||||
if built:
|
||||
pv.set_audio(_audio)
|
||||
set_player_model(pv)
|
||||
_model_built = true
|
||||
@@ -1391,21 +1456,43 @@ func _on_main_set(vid: int) -> void:
|
||||
net_play.player_view = pv
|
||||
if pv.anim and pv.anim.has_signal("motion_event"):
|
||||
pv.anim.motion_event.connect(_on_local_motion_event)
|
||||
if pc and pc.has_signal("anim_state"):
|
||||
pc.anim_state.connect(func(s: String):
|
||||
if is_instance_valid(player) and player.has_method("set_anim_state"):
|
||||
player.set_anim_state(s))
|
||||
if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state):
|
||||
pc.anim_state.connect(_on_main_anim_state)
|
||||
if equip_model:
|
||||
equip_model.set_race(int(e.get("race", 0)))
|
||||
equip_model.refresh()
|
||||
else:
|
||||
pv.queue_free()
|
||||
var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO))
|
||||
if world and world.has_method("set_focus_position"):
|
||||
world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块
|
||||
if world and world.has_method("sample_height"):
|
||||
p.y = float(world.call("sample_height", p.x, p.z))
|
||||
player.position = p
|
||||
_main_retry_at = Time.get_ticks_msec() + MAIN_MODEL_RETRY_MS
|
||||
push_warning("GameScene: 主角模型加载失败(vid=%d race=%d attempt=%d/%d)" % [vid, int(e.get("race", 0)), _main_attempts, MAIN_MODEL_MAX_ATTEMPTS])
|
||||
pv.free()
|
||||
# Polling for model readiness must not snap a moving player to the initial
|
||||
# server position on every frame or on duplicate main-character events.
|
||||
if _main_placed_vid == vid:
|
||||
return
|
||||
_place_player_at_net_pos(net_pos)
|
||||
_main_placed_vid = vid
|
||||
|
||||
func _create_main_view() -> Node3D:
|
||||
return PlayerView.new()
|
||||
|
||||
func _on_main_anim_state(state: String) -> void:
|
||||
if is_instance_valid(player) and player.has_method("set_anim_state"):
|
||||
player.set_anim_state(state)
|
||||
|
||||
func _sync_main_character() -> void:
|
||||
if not _main_sync_ready or _main_loading or player == null or not is_instance_valid(player):
|
||||
return
|
||||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||||
return
|
||||
var vid := int(client.get_main_vid())
|
||||
if vid == 0:
|
||||
return
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
# Do not mark the attempt until the authoritative character row exists. The
|
||||
# phase packet can precede the row by several frames on a busy connection.
|
||||
if e.is_empty():
|
||||
return
|
||||
_on_main_set(vid)
|
||||
|
||||
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
|
||||
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Real GameScene/assets, delayed authoritative row, no network or account writes.
|
||||
extends SceneTree
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
var failed := false
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var output := OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if output.is_empty():
|
||||
output = ProjectSettings.globalize_path("res://../build/rendering/gamescene-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
var fc := Fixtures.FakeClient.new()
|
||||
root.add_child(fc)
|
||||
var scene := GameScene.new()
|
||||
root.add_child(scene)
|
||||
await scene.setup(fc, AssetRoot.path(), "OutdoorA1/metin2_map_a1")
|
||||
# Choose C1 to exercise late map correction too (setup initially had no VID).
|
||||
var use_c1 := "--c1" in OS.get_cmdline_user_args()
|
||||
var server := Vector2(959109, 269267) if use_c1 else Vector2(454600, 934000)
|
||||
fc.main = 1000
|
||||
fc.ents[1000] = {"vid": 1000, "name": "RenderTest", "race": 0,
|
||||
"pos": Vector3(server.x * 0.01, 0, -server.y * 0.01),
|
||||
"pos_cm": server, "is_main": true, "angle_deg": 0.0,
|
||||
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
|
||||
for i in 30: await process_frame
|
||||
var expected := MapCoord.to_world(fc.ents[1000].pos)
|
||||
expected.y = scene.world.sample_height(expected.x, expected.z)
|
||||
failed = not scene._model_built or scene.player.position.distance_to(expected) > 0.02
|
||||
if use_c1: failed = failed or scene.map_path != "OutdoorC1/metin2_map_c1"
|
||||
var surfaces := 0
|
||||
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh: surfaces += mi.mesh.get_surface_count()
|
||||
failed = failed or surfaces == 0 or not scene._map_loaded()
|
||||
if scene._model_built and scene.player.anim:
|
||||
scene.player.anim.set_time(0.5)
|
||||
scene.player.anim.set_process(false)
|
||||
scene.cam.snap_to_target()
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
|
||||
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
|
||||
"world_position": str(scene.player.position), "expected": str(expected),
|
||||
"surfaces": surfaces, "map_report": scene.world.get_load_report(),
|
||||
"engine": Engine.get_version_info(), "viewport": str(root.size),
|
||||
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
|
||||
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
||||
"scope": "real GameScene, delayed main row, no main signal; no server/40250 parity"}
|
||||
var hashes := {}
|
||||
for path in ["res://game_scene.gd", "res://ui/player_view.gd", "res://net/mapcoord.gd",
|
||||
"res://gamescene_visual_test.gd", "res://bin/libmtgodot.macos.template_debug.dylib"]:
|
||||
hashes[path] = FileAccess.get_sha256(path)
|
||||
if scene._model_built:
|
||||
for property in ["gr2_path", "hair_gr2", "hair_skin"]:
|
||||
var path := String(scene.player.model.get(property))
|
||||
if not path.is_empty(): hashes[path] = FileAccess.get_sha256(path)
|
||||
var motion := String(scene.player.anim.get("anim_path")) if scene.player.anim else ""
|
||||
if not motion.is_empty(): hashes[motion] = FileAccess.get_sha256(motion)
|
||||
report["sha256"] = hashes
|
||||
report["animation_sample_seconds"] = 0.5
|
||||
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
|
||||
file.store_string(JSON.stringify(report, "\t"))
|
||||
file.close()
|
||||
print("GAMESCENE_VISUAL: ", JSON.stringify(report))
|
||||
scene.queue_free()
|
||||
fc.queue_free()
|
||||
await process_frame
|
||||
quit(1 if failed else 0)
|
||||
@@ -0,0 +1,116 @@
|
||||
extends SceneTree
|
||||
|
||||
const Scene = preload("res://game_scene.gd")
|
||||
const View = preload("res://ui/player_view.gd")
|
||||
|
||||
class Client extends Node:
|
||||
var main := 0
|
||||
var rows := {}
|
||||
func get_main_vid() -> int: return main
|
||||
func get_entity(vid: int) -> Dictionary: return rows.get(vid, {})
|
||||
|
||||
class StubView extends Node3D:
|
||||
var anim: Node
|
||||
var succeeds := true
|
||||
var during_build := Callable()
|
||||
func build(_assets: String, _race: int, _pump: Callable) -> bool:
|
||||
if during_build.is_valid(): during_build.call()
|
||||
return succeeds
|
||||
func set_audio(_node: Node) -> void: pass
|
||||
|
||||
class Harness extends Scene:
|
||||
var builds := 0
|
||||
var succeeds := true
|
||||
var during_build := Callable()
|
||||
func _create_main_view() -> Node3D:
|
||||
builds += 1
|
||||
var view := StubView.new()
|
||||
view.succeeds = succeeds
|
||||
view.during_build = during_build
|
||||
return view
|
||||
|
||||
class Controller extends Node:
|
||||
signal anim_state(state: String)
|
||||
var player: Node3D
|
||||
|
||||
class Camera extends Camera3D:
|
||||
var target: Node3D
|
||||
|
||||
var failures := 0
|
||||
func check(value: bool, label: String) -> void:
|
||||
if not value:
|
||||
failures += 1
|
||||
printerr("FAIL: " + label)
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func run() -> void:
|
||||
var client := Client.new()
|
||||
var scene := Harness.new()
|
||||
root.add_child(client)
|
||||
root.add_child(scene)
|
||||
scene.set_process(false)
|
||||
scene.client = client
|
||||
scene._assets = "synthetic-assets"
|
||||
scene.player = Node3D.new()
|
||||
scene.add_child(scene.player)
|
||||
scene.pc = Controller.new()
|
||||
scene.add_child(scene.pc)
|
||||
scene.cam = Camera.new()
|
||||
scene.add_child(scene.cam)
|
||||
scene._sync_main_character()
|
||||
check(scene.builds == 0, "setup incomplete does not build")
|
||||
scene._main_sync_ready = true
|
||||
client.main = 10
|
||||
scene._sync_main_character()
|
||||
check(scene.builds == 0, "late entity is awaited")
|
||||
client.rows[10] = {"pos": Vector3(20, 0, -30)}
|
||||
scene._sync_main_character()
|
||||
check(scene.builds == 0, "missing race does not build")
|
||||
check(scene.player.position == Vector3(20, 0, 30), "late row places placeholder")
|
||||
client.rows[10].race = 0
|
||||
scene.during_build = func(): scene._on_main_set(10)
|
||||
scene._sync_main_character()
|
||||
check(scene.builds == 1 and scene._model_built, "reentrant network event creates one view")
|
||||
check(scene.cam.target == scene.player and scene.pc.player == scene.player, "replacement updates consumers")
|
||||
scene.player.position = Vector3(25, 0, 35)
|
||||
for i in 5:
|
||||
scene._sync_main_character()
|
||||
scene._on_main_set(10)
|
||||
check(scene.builds == 1, "duplicate main events are idempotent")
|
||||
check(scene.player.position == Vector3(25, 0, 35), "polling does not undo movement")
|
||||
client.main = 20
|
||||
client.rows[20] = {"race": 1, "pos": Vector3(40, 0, -50)}
|
||||
scene.during_build = Callable()
|
||||
scene.succeeds = false
|
||||
scene._sync_main_character()
|
||||
check(scene.builds == 2 and not scene._model_built, "failed replacement is not marked ready")
|
||||
for i in 5: scene._sync_main_character()
|
||||
check(scene.builds == 2, "failure does not retry every frame")
|
||||
scene._main_retry_at = 0
|
||||
scene.succeeds = true
|
||||
scene._sync_main_character()
|
||||
check(scene.builds == 3 and scene._model_built, "transient failure recovers")
|
||||
check(scene.pc.anim_state.get_connections().size() == 1, "replacement does not duplicate animation subscriptions")
|
||||
var previous = scene.player
|
||||
client.main = 30
|
||||
client.rows[30] = {"race": 0, "pos": Vector3.ZERO}
|
||||
scene.during_build = func(): client.main = 40
|
||||
scene._sync_main_character()
|
||||
check(scene.player == previous, "obsolete build is discarded")
|
||||
client.rows[40] = {"race": 0, "pos": Vector3.ZERO}
|
||||
scene.during_build = Callable()
|
||||
scene.succeeds = false
|
||||
for i in 5:
|
||||
scene._main_retry_at = 0
|
||||
scene._sync_main_character()
|
||||
check(scene._main_attempts == 3, "permanent failure has bounded attempts")
|
||||
var view := View.new()
|
||||
check(not view.build("missing-assets", 8), "unsupported race rejected")
|
||||
view.free()
|
||||
scene.queue_free()
|
||||
client.queue_free()
|
||||
await process_frame
|
||||
print("main_character_sync_test: failures=", failures)
|
||||
quit(1 if failures else 0)
|
||||
@@ -305,9 +305,8 @@ func _rebuild_model() -> void:
|
||||
# Expansion armor textures live beside their GR2, outside the base PC pack.
|
||||
var body_path := String(pv.model.get("gr2_path"))
|
||||
pv.model.set("texture_dir", body_path.get_base_dir())
|
||||
# Shape zero uses the head/hair already included in the armor mesh.
|
||||
if int(c.get("hair_part", 0)) == 0:
|
||||
pv.model.set("hair_gr2", "")
|
||||
pv.model.set("lod_enabled", false)
|
||||
pv.model.set("flip_winding", true)
|
||||
pv._ground_model()
|
||||
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
|
||||
if pv.anim and FileAccess.file_exists(intro):
|
||||
|
||||
@@ -29,7 +29,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
|
||||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||||
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
_assets_root = assets_root
|
||||
if not ClassDB.class_exists("Metin2Model"):
|
||||
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
|
||||
return false
|
||||
var cls: String = CLASS_OF[race & 3]
|
||||
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
|
||||
@@ -46,9 +46,18 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
model.set("texture_dir", base)
|
||||
if pump.is_valid(): pump.call()
|
||||
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
|
||||
if body != "":
|
||||
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
|
||||
if pump.is_valid(): pump.call()
|
||||
if body == "":
|
||||
model.free()
|
||||
model = null
|
||||
return false
|
||||
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
|
||||
# This view is built off-tree: set_gr2_path only stores the path until ready.
|
||||
# Validate the source explicitly rather than reading a not-yet-loaded report.
|
||||
if String(model.call("probe_gr2", body)).contains("FAILED"):
|
||||
model.free()
|
||||
model = null
|
||||
return false
|
||||
if pump.is_valid(): pump.call()
|
||||
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
|
||||
if hair != "":
|
||||
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
|
||||
|
||||
Reference in New Issue
Block a user