feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+223 -37
View File
@@ -64,6 +64,7 @@ const AtlasUI = preload("res://ui/atlas_ui.gd")
const DungeonState = preload("res://dungeon_state.gd")
const WorldTime = preload("res://world/world_time.gd")
const Weather = preload("res://fx/weather.gd")
const QuestCurtain = preload("res://ui/quest_curtain.gd")
# 龙魂是协议兼容层的一部分,但龙魂 40250 服务器没有这个功能。
# 保留 DragonSoulUI / ds_refine 代码供后续兼容,当前客户端不创建入口、不消费背包右键。
@@ -78,6 +79,7 @@ var net_world: Node
var net_play: Node
var hud: Node
var ui: CanvasLayer # UiManager
var quest_curtain: QuestCurtain
var cursor_manager: Node
var mouse_controller: Node
var proto: Node # Metin2Proto
@@ -129,6 +131,7 @@ var _main_attempts := 0
var _main_retry_at := 0
var _main_placed_vid := 0
var _main_map_vid := 0
var _setup_completed := false
const MAIN_MODEL_MAX_ATTEMPTS := 3
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里
@@ -194,6 +197,7 @@ func setup(m2client: Node, assets_root: String,
pc.player = player
pc.camera = cam
pc.world = world
pc.active = false
add_child(pc)
_mount = Node3D.new()
@@ -210,6 +214,8 @@ func setup(m2client: Node, assets_root: String,
net_world.set_local_node(player)
net_world.entity_added.connect(_on_pickable_added)
net_world.entity_removed.connect(_on_pickable_removed)
if client.has_signal("entity_dead"):
client.entity_dead.connect(func(vid: int) -> void: _drop_stale_pickables(vid))
if _ui_mobile:
hud = MobileUiRoot.new()
@@ -225,7 +231,7 @@ func setup(m2client: Node, assets_root: String,
net_play = NetPlay.new()
add_child(net_play)
net_play.setup(client, pc, net_world, hud)
net_play.setup(client, pc, net_world, hud, _audio)
net_play.camera = cam
# §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。
if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"):
@@ -236,6 +242,18 @@ func setup(m2client: Node, assets_root: String,
if _assets != "":
net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表
# 主角必须先于后续的 proto/UI/远端实体重活建立。实网一次可收到近 200 个
# GC_CHARACTER_ADD;若等整个 setup() 尾部才订阅,怪物模型同步加载会把本地角色
# 卡在占位胶囊几十秒,并且装备控制器也会错过 entity_main_set。
client.entity_main_set.connect(_on_main_set)
_main_sync_ready = true
if client.get_main_vid() != 0: # 信号可能在 GameScene 创建前已经派发
if net_world and net_world.has_method("set_local_vid"):
net_world.set_local_vid(client.get_main_vid())
if net_play and net_play.has_method("_on_main_set"):
net_play._on_main_set(client.get_main_vid())
_on_main_set(client.get_main_vid())
await _yield()
_audio = Audio.new()
add_child(_audio)
@@ -251,11 +269,15 @@ func setup(m2client: Node, assets_root: String,
# UI 层 + 物品 proto + 背包窗(I 键开关)
ui = UiManager.new()
add_child(ui)
quest_curtain = QuestCurtain.new()
ui.add_child(quest_curtain)
cursor_manager = CursorManager.new()
add_child(cursor_manager)
cursor_manager.setup(assets_root)
mouse_controller = MouseController.new()
add_child(mouse_controller)
mouse_controller.setup(ui, cursor_manager, _audio)
mouse_controller.world_drop_handler = _on_world_item_drop
# 聊天窗(Enter 聚焦输入)
chat = ChatUI.new()
@@ -290,17 +312,30 @@ func setup(m2client: Node, assets_root: String,
break
skill_fx = SkillFx.new()
skill_fx.setup(fx, skill_table)
if net_world:
net_world.skill_table = skill_table
net_world.fx = fx
skills = SkillUI.new()
add_child(skills)
skills.setup(client, skill_table, ui)
if skills.has_signal("skill_use_requested"):
skills.skill_use_requested.connect(func(sid: int):
if quickbar and quickbar.has_method("activate_skill_direct"):
quickbar.activate_skill_direct(sid)
)
char_status_ui = CharStatusUI.new()
add_child(char_status_ui)
char_status_ui.setup(ui, client, assets_root)
char_status_ui.setup(ui, client, assets_root, skill_table, mouse_controller)
quickbar = Quickbar.new()
add_child(quickbar)
quickbar.item_mouse = mouse_controller
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
quickbar.setup(client, skill_table, ui, func() -> Node: return player, assets_root)
if hud:
if "quickbar" in hud:
hud.set("quickbar", quickbar)
if hud.has_method("set_status_visible"):
hud.set_status_visible(false)
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
if net_play:
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
@@ -322,6 +357,29 @@ func setup(m2client: Node, assets_root: String,
if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid):
_queue_shot(sid)
)
if quickbar.has_signal("character_requested"):
quickbar.character_requested.connect(func():
if char_status_ui: char_status_ui.toggle()
)
if quickbar.has_signal("inventory_requested"):
quickbar.inventory_requested.connect(func():
if inventory: inventory.toggle()
)
if quickbar.has_signal("messenger_requested"):
quickbar.messenger_requested.connect(func():
if friend_ui: friend_ui.toggle()
)
if quickbar.has_signal("safebox_requested"):
quickbar.safebox_requested.connect(_on_safebox_button_clicked)
if quickbar.has_signal("system_requested"):
quickbar.system_requested.connect(func():
if system_menu_ui: system_menu_ui.toggle()
)
if quickbar.has_signal("chat_requested"):
quickbar.chat_requested.connect(func():
if chat and chat.has_method("toggle_log"):
chat.toggle_log()
)
# 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING
# 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6
@@ -380,6 +438,13 @@ func setup(m2client: Node, assets_root: String,
var refresh_affects := func(_a = null): if hud and hud.has_method("set_affects"): hud.set_affects(client.get_affects())
client.affect_added.connect(refresh_affects)
client.affect_removed.connect(refresh_affects)
client.affect_added.connect(func(a: Dictionary):
var t := int(a.get("type", 0))
_set_player_affect(t, true)
)
client.affect_removed.connect(func(t: int):
_set_player_affect(t, false)
)
await _yield()
if ClassDB.class_exists("Metin2Proto") and assets_root != "":
proto = ClassDB.instantiate("Metin2Proto")
@@ -387,6 +452,8 @@ func setup(m2client: Node, assets_root: String,
var ip := assets_root.path_join("locale/locale/en/item_proto")
if FileAccess.file_exists(ip):
proto.call("load_item_proto", ip)
if quickbar and "proto" in quickbar:
quickbar.proto = proto
await _yield()
if chat and chat.has_method("set_proto"):
chat.set_proto(proto)
@@ -408,8 +475,6 @@ func setup(m2client: Node, assets_root: String,
net_world.title_name_resolver = func(g: int) -> String:
var k := "PVP_LEVEL%d" % g
return _tt_loc.t(k) if _tt_loc.has(k) else ""
# 怪 / NPC 真模型:race -> mob_proto.name -> monster/npc 目录
net_world.set_model_factory(_make_entity_model)
if assets_root != "":
item_list = ItemListDB.new()
item_list.load_file(assets_root.path_join("locale/locale/common/item_list.txt"))
@@ -424,8 +489,15 @@ func setup(m2client: Node, assets_root: String,
equip_model = EquipModel.new()
add_child(equip_model)
equip_model.setup(client, item_list, func() -> Node: return player, assets_root)
equip_model.effect_registry = fx
equip_model.main_getter = func() -> int: return client.get_main_vid()
equip_model.proto = proto # armor shape 走 item_proto values[3] + specular
# 主角已在上面的优先路径建立时,装备相关网络信号早于 EquipModel 的订阅。
# 用当前权威实体与装备快照补一次,保证首次进场立即挂盔甲、武器和发型。
var current_main := int(client.get_main_vid())
if current_main != 0:
equip_model.set_race(int(client.get_entity(current_main).get("race", -1)))
equip_model.refresh()
# 地面掉落物
ground_items = GroundItems.new()
add_child(ground_items)
@@ -438,12 +510,17 @@ func setup(m2client: Node, assets_root: String,
if _poss_loc.has("POSSESSIVE_MORPHENE"):
_possessive = _poss_loc.t("POSSESSIVE_MORPHENE")
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list,
func() -> Camera3D: return cam, _possessive)
func() -> Camera3D: return cam, _possessive, func() -> Node: return world)
pc.set_input_surfaces(cursor_manager, ui, ground_items,
Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click"))
Callable(net_play, "on_manual_move"), Callable(net_play, "on_ground_click"))
view_equipment_ui = ViewEquipmentUI.new()
add_child(view_equipment_ui)
view_equipment_ui.setup(client, ui, proto, item_list)
# 最后再升级怪/NPC/远端玩家真模型。实网的首次实体 burst 接近 200 个,
# set_model_factory 会同步重建已有节点;放在主角装备和技能 UI 之前会让本地
# 角色看似长期裸装,并使冒烟测试在控制器建立前误判完成。
if net_world:
net_world.set_model_factory(_make_entity_model)
# 选魔石窗(EventManager [SELECT_ITEM] -> interfacemodule.BINARY_OpenSelectItemWindow
select_item_ui = SelectItemUI.new()
@@ -569,6 +646,13 @@ func setup(m2client: Node, assets_root: String,
# §8.4「看他国玩家目标框」radio -> net_playuitarget.TargetBoard.Open 前置过滤)。
if net_play and net_play.has_method("set_view_other_empire_target") and game_option_ui.has_method("display_value"):
net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1)
# §8.4「聊天显示」radio -> chat.
if chat and game_option_ui.has_method("display_value"):
var vc: bool = (game_option_ui.display_value("view_chat") == 1)
if chat.has_method("set_view_chat"):
chat.set_view_chat(vc)
elif "visible" in chat:
chat.visible = vc
system_menu_ui = SystemMenuUI.new()
add_child(system_menu_ui)
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
@@ -582,24 +666,21 @@ func setup(m2client: Node, assets_root: String,
if _ui_mobile and hud and hud.has_method("bind_controls"):
_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 会
# 在玩家身上叠一个大蓝胶囊)。
if net_world and net_world.has_method("set_local_vid"):
net_world.set_local_vid(client.get_main_vid())
if net_play and net_play.has_method("_on_main_set"):
net_play._on_main_set(client.get_main_vid())
_on_main_set(client.get_main_vid())
# setup() 是协程 —— 进来时进游戏的 spawn burst 早被 M2Client.pump_game 抽干、
# net_world 还没连上信号。把当前所有实体(怪 / NPC / 别的玩家)补建一遍。
if net_world and net_world.has_method("catch_up"):
net_world.catch_up()
# 40250 IntroLoading 对齐:若主角真模型尚未构建完成,在场景就绪前同步构建
if not _model_built and client and client.has_method("get_main_vid") and int(client.get_main_vid()) != 0:
_sync_main_character()
set_process_unhandled_input(true)
_setup_completed = true
if pc:
pc.active = true
if pc.has_method("stop"):
pc.stop()
func _setup_mobile_ui() -> void:
# MobileUiRoot owns the presentation and touch layer; every feature node in
@@ -649,6 +730,8 @@ func _drop_stale_pickables(drop_vid: int = -1) -> void:
list.remove_at(i)
elif n.is_queued_for_deletion():
list.remove_at(i)
elif bool(n.get_meta("dead", false)):
list.remove_at(i)
elif drop_vid >= 0 and int(n.get_meta("vid", -1)) == drop_vid:
list.remove_at(i)
@@ -734,11 +817,13 @@ func _diag_tick() -> void:
printerr("GAMESCENE_DIAG: ", JSON.stringify(d))
# MT_DIAG_SHOT=<png>:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
var shot := OS.get_environment("MT_DIAG_SHOT")
if not shot.is_empty() and _diag_done:
var shot_ready := _model_built and is_instance_valid(equip_model) and is_instance_valid(quickbar)
if not shot.is_empty() and (_diag_done or shot_ready):
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
if img != null:
img.save_png(shot)
if img.save_png(shot) == OK:
_diag_done = true
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
func _guild_mark_upload_image() -> Image:
@@ -846,7 +931,11 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto
var _motion_effect_target: WeakRef
func _on_skill_cast_started(_skill: int, target_vid: int) -> void:
func _on_skill_cast_started(skill: int, target_vid: int) -> void:
if world and world.has_method("sample_height") and is_instance_valid(player):
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
if pc and pc.has_method("stop"):
pc.stop()
_motion_effect_target = null
var target: Node3D = null
if client and target_vid == int(client.get_main_vid()):
@@ -855,6 +944,36 @@ func _on_skill_cast_started(_skill: int, target_vid: int) -> void:
target = net_world.node_for(target_vid)
if is_instance_valid(target):
_motion_effect_target = weakref(target)
if target != player and is_instance_valid(player):
var to: Vector3 = target.global_position - player.global_position
to.y = 0.0
if to.length() > 0.01:
player.rotation.y = atan2(to.x, to.z)
var pv_node: Node = player if is_instance_valid(player) and player.has_method("play_skill_motion") else (net_play.player_view if net_play and "player_view" in net_play else null)
var dur := 2.0
var is_charge := false
var cancel_enable := false
if skill_table:
if skill_table.has_method("is_charge_skill"):
is_charge = skill_table.is_charge_skill(skill)
if is_instance_valid(pv_node) and pv_node.has_method("play_skill_motion"):
var mname: String = skill_table.motion_name_of(skill)
if mname != "":
var played: bool = pv_node.play_skill_motion(mname, _skill_master(skill))
if played and "anim" in pv_node and pv_node.anim:
if pv_node.anim.has_method("get_duration"):
var d: float = float(pv_node.anim.get_duration())
if d > 0.0:
dur = d
if pv_node.anim.has_method("get_loop_data"):
var ld: Dictionary = pv_node.anim.get_loop_data()
if bool(ld.get("cancel_enable", false)):
cancel_enable = true
if net_play:
if net_play.has_method("start_skill_cast"):
net_play.start_skill_cast(dur, is_charge, cancel_enable)
else:
net_play._using_skill = true
func _on_local_motion_event_detailed(event: Dictionary) -> void:
# Keep sound/projectile dispatch exactly once, independent of visual routing.
@@ -870,6 +989,10 @@ func _on_local_motion_event_detailed(event: Dictionary) -> void:
else:
_on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos)
func _set_player_affect(type: int, visible: bool) -> void:
if player and player.has_method("set_affect"):
player.set_affect(type, visible, fx)
# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。
# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6
func _queue_shot(skill: int) -> void:
@@ -886,12 +1009,18 @@ func _on_quest_camera_event(kind: String, setting: Dictionary, blendtime: float)
return
match kind:
"SET_CAMERA":
if quest_curtain:
quest_curtain.open()
if cam.has_method("set_event_camera"):
cam.set_event_camera(setting)
"BLEND_CAMERA":
if quest_curtain:
quest_curtain.open(maxf(0.2, blendtime))
if cam.has_method("blend_event_camera"):
cam.blend_event_camera(setting, blendtime)
"RESTORE_CAMERA":
if quest_curtain:
quest_curtain.close()
if cam.has_method("set_default_camera"):
cam.set_default_camera()
@@ -938,6 +1067,8 @@ func _clear_quest_presentation() -> void:
if _quest_fade_rect and is_instance_valid(_quest_fade_rect):
_quest_fade_rect.color.a = 0.0
_quest_fade_rect.visible = false
if quest_curtain:
quest_curtain.close(0.2)
if cam and cam.has_method("set_default_camera"):
cam.set_default_camera()
@@ -1036,10 +1167,20 @@ func _place_player_at_net_pos(pos: Vector3) -> void:
world.call("set_focus_position", wp.x, wp.z)
if world and world.has_method("sample_height"):
wp.y = float(world.call("sample_height", wp.x, wp.z))
player.global_position = wp
if net_play and "_last_sent_pos" in net_play:
net_play._last_sent_pos = player.global_position
if cam and cam.has_method("snap_to_target"):
if player.is_inside_tree():
player.global_position = wp
else:
player.position = wp
if pc and pc.has_method("stop"):
pc.stop()
if net_play:
if "_last_sent_pos" in net_play:
net_play._last_sent_pos = player.global_position if player.is_inside_tree() else player.position
if net_play.has_method("_clear_reserved"):
net_play._clear_reserved()
if net_play.has_method("_clear_auto_attack"):
net_play._clear_auto_attack()
if cam and cam.has_method("snap_to_target") and cam.is_inside_tree() and player.is_inside_tree():
cam.snap_to_target()
# 背包道具右键:按当前打开的窗决定动作(商店卖 / 交易放 / 仓库存)。
@@ -1188,7 +1329,7 @@ func _unhandled_input(e: InputEvent) -> void:
_emote(n)
elif _quick_page_mode and n < 4 and quickbar:
quickbar.set_page(n)
elif n < 4 and quickbar:
elif n < 8 and quickbar:
quickbar.activate(n)
func _set_names(visible: bool) -> void:
@@ -1206,11 +1347,30 @@ func _on_display_option(key: String, value: int) -> void:
net_world.set_empire_name_mode(value == 1)
elif key == "target_board" and net_play and net_play.has_method("set_view_other_empire_target"):
net_play.set_view_other_empire_target(value == 1)
elif key == "view_chat" and chat:
if chat.has_method("set_view_chat"):
chat.set_view_chat(value == 1)
elif "visible" in chat:
chat.visible = (value == 1)
func _send_command(text: String) -> void:
if client and client.has_method("say"):
client.say(0, text)
func _on_safebox_button_clicked() -> void:
if safebox_ui and safebox_ui.has_method("is_open") and safebox_ui.is_open():
safebox_ui.close()
elif safebox_ui and safebox_ui.has_method("open"):
safebox_ui.open()
# 对齐 40250 interfacemodule.py:OpenSafeboxWindow —— 打开仓库同时打开背包,
# 让玩家能看到自己物品并右键存入仓库。
if inventory and inventory.has_method("open"):
inventory.open()
elif mall_ui and mall_ui.has_method("is_open") and mall_ui.is_open():
mall_ui.close()
else:
_send_command("/click_mall")
func _toggle_horse() -> void:
if client == null:
return
@@ -1312,7 +1472,7 @@ func _make_entity_model(d: Dictionary) -> Node3D:
return null
var pv := RemotePlayerView.new()
var parts: Array = d.get("parts", []) if d.get("parts", []) is Array else []
if pv.build_remote(_assets, race, parts, item_list, proto, pump):
if pv.build_remote(_assets, race, parts, item_list, proto, pump, fx):
pv.set_audio(_audio)
_remote_player_view_cache[race] = true
return pv
@@ -1337,11 +1497,14 @@ func set_player_model(node: Node3D) -> void:
return
var old := player
node.position = old.position
node.rotation = old.rotation
node.name = "LocalPlayer"
add_child(node)
player = node
cam.target = node
pc.player = node
if pc and pc.has_method("stop"):
pc.stop()
if net_world and net_world.has_method("set_local_node"):
net_world.set_local_node(node)
old.queue_free()
@@ -1562,8 +1725,12 @@ func _on_main_set(vid: int) -> void:
return
if built:
pv.set_audio(_audio)
pv.set_fx(fx)
set_player_model(pv)
_model_built = true
if client and client.has_method("get_affects"):
for a in client.get_affects():
pv.set_affect(int(a.get("type", 0)), true, fx)
if net_play:
net_play.player_view = pv
if pv.anim and pv.anim.has_signal("motion_event_detailed"):
@@ -1585,6 +1752,10 @@ func _on_main_set(vid: int) -> void:
return
_place_player_at_net_pos(net_pos)
_main_placed_vid = vid
if pc:
pc.active = _setup_completed
if pc.has_method("stop"):
pc.stop()
func _create_main_view() -> Node3D:
return PlayerView.new()
@@ -1616,14 +1787,29 @@ func lifecycle_audio() -> Node:
func _make_placeholder_player() -> Node3D:
var root := Node3D.new()
root.name = "LocalPlayer"
var mesh := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.35
cap.height = 1.8
mesh.mesh = cap
mesh.position.y = 0.9
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.35, 0.6, 1.0)
mesh.material_override = mat
root.add_child(mesh)
return root
func _on_world_item_drop(payload: Dictionary, screen_pos: Vector2) -> bool:
if pc == null or pc.camera == null or client == null:
return false
var cam: Camera3D = pc.camera
var from := cam.project_ray_origin(screen_pos)
var dir := cam.project_ray_normal(screen_pos)
var best: Node3D = null
var best_t := 1e20
for n in pc.pickables:
if not is_instance_valid(n) or bool(n.get_meta("dead", false)):
continue
var t := float(pc.call("_ray_pick_t", from, dir, n)) if pc.has_method("_ray_pick_t") else -1.0
if t >= 0.0 and t < best_t:
best_t = t
best = n
if best != null:
var target_vid := int(best.get_meta("vid", 0))
if target_vid > 0:
var win := int(payload.get("window", 1))
var cell := int(payload.get("cell", 0))
var count := int(payload.get("count", 1))
if client.has_method("give_item"):
return bool(client.give_item(target_vid, win, cell, count))
return false