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:
+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,避免切屏时重复监听暂停 / 恢复通知。
|
||||
|
||||
Reference in New Issue
Block a user