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:
shen
2026-09-08 08:52:30 +08:00
parent 8092ff44e2
commit c3a6fb973e
11 changed files with 620 additions and 23 deletions
+101 -14
View File
@@ -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,避免切屏时重复监听暂停 / 恢复通知。