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
+208 -26
View File
@@ -8,6 +8,8 @@
# M2Client.ground_item_added(Dict{vid,vnum,pos,owner}) / ground_item_removed(vid) 驱动。
extends Node
signal cannot_pick_item(owner_name: String)
const PICKUP_RANGE := 3.0
# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落
@@ -49,12 +51,56 @@ var proto: Node
var item_list: RefCounted
var _player_getter: Callable
var _camera_getter: Callable
var _world_getter: Callable
var _world: Node
var _by_vid := {} # vid -> Node3D
var _next_tcp_time_ms := 0
func get_pickable_distance() -> float:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p and bool(p.get_meta("mounted", false)):
return 5.0
return PICKUP_RANGE
func _get_main_player_name() -> String:
if client == null:
return ""
if client.has_method("get_main_vid") and client.has_method("get_entity"):
var vid := int(client.get_main_vid())
if vid > 0:
var e: Dictionary = client.get_entity(vid)
var n := String(e.get("name", ""))
if n != "":
return n
if client.has_method("get_character_name"):
return String(client.get_character_name())
return ""
func _on_cannot_pick_item(owner: String) -> void:
cannot_pick_item.emit(owner)
print("[拾取] 这不是你的物品,无法拾取!(所有者: %s)" % owner)
func _is_rare_drop(vnum: int, d: Dictionary) -> bool:
if vnum == 1:
return false
if vnum in [25040, 50513, 70024, 70031]:
return true
if proto and proto.has_method("item"):
var pd: Dictionary = proto.item(vnum)
var itype := int(pd.get("type", 0))
if itype in [1, 2] and (vnum % 10) >= 7:
return true
if int(d.get("rarity", 0)) >= 2 or int(d.get("grade", 0)) >= 3:
return true
return false
var assets_override := ""
var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"SetItemTextTailOwner :722
func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
proto_node: Node = null, il: RefCounted = null,
camera_getter: Callable = Callable(), possessive := "") -> void:
camera_getter: Callable = Callable(), possessive := "",
world_getter: Callable = Callable()) -> void:
client = m2client
_possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc
mount = mount_node
@@ -62,6 +108,7 @@ func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
item_list = il
_player_getter = player_getter
_camera_getter = camera_getter
_world_getter = world_getter
if client.has_signal("ground_item_added"):
client.ground_item_added.connect(_on_added)
if client.has_signal("ground_item_removed"):
@@ -71,6 +118,15 @@ func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
for d in client.get_ground_items():
_on_added(d)
func _to_world_pos(raw_pos: Variant) -> Vector3:
if not (raw_pos is Vector3):
return Vector3.ZERO
var pos: Vector3 = MapCoord.to_world(raw_pos)
var w: Node = _world_getter.call() if _world_getter.is_valid() else _world
if w and w.has_method("sample_height"):
pos.y = float(w.call("sample_height", pos.x, pos.z)) + 0.1
return pos
func _on_added(d: Dictionary) -> void:
var vid := int(d.get("vid", 0))
if vid == 0:
@@ -78,9 +134,11 @@ func _on_added(d: Dictionary) -> void:
var vnum := int(d.get("vnum", 0))
var owner := String(d.get("owner", ""))
var item_color := _item_color(vnum, d)
var pos := _to_world_pos(d.get("pos", Vector3.ZERO))
if _by_vid.has(vid):
var existing: Node3D = _by_vid[vid]
if is_instance_valid(existing):
existing.position = pos
var existing_tag := existing.get_node("tag") as Label3D
existing_tag.text = _name_for(vnum)
existing_tag.modulate = item_color
@@ -91,18 +149,51 @@ func _on_added(d: Dictionary) -> void:
return
var node := Node3D.new()
node.name = "drop_%d" % vid
node.position = d.get("pos", Vector3.ZERO)
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.25, 0.25, 0.25)
mesh.mesh = box
mesh.position.y = 0.3
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.85, 0.3)
mat.emission_enabled = true
mat.emission = Color(0.6, 0.5, 0.1)
mesh.material_override = mat
node.add_child(mesh)
node.position = pos
var root := "" if assets_override == "__none__" else (assets_override if assets_override != "" else (AssetRoot.path() if AssetRoot.available() else ""))
var model_path := ""
if root != "":
if vnum == 1:
for p in ["item/ymir work/item/etc/money.gr2", "item/etc/money.gr2"]:
var fp := root.path_join(p)
if FileAccess.file_exists(fp):
model_path = fp
break
else:
for p in ["item/ymir work/item/etc/item_bag.gr2", "item/etc/item_bag.gr2"]:
var fp := root.path_join(p)
if FileAccess.file_exists(fp):
model_path = fp
break
if model_path != "" and ClassDB.class_exists("Metin2Model"):
var model: Node3D = ClassDB.instantiate("Metin2Model")
model.name = "model"
model.set("flip_winding", true)
model.set("texture_dir", model_path.get_base_dir())
model.set("gr2_path", model_path)
node.add_child(model)
else:
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.25, 0.25, 0.25)
mesh.mesh = box
mesh.position.y = 0.15
var mat := StandardMaterial3D.new()
if vnum == 1:
mat.albedo_color = Color(1.0, 0.85, 0.2)
mat.metallic = 0.8
mat.roughness = 0.2
mat.emission_enabled = true
mat.emission = Color(0.5, 0.4, 0.1)
else:
mat.albedo_color = Color(1.0, 0.85, 0.3)
mat.emission_enabled = true
mat.emission = Color(0.6, 0.5, 0.1)
mesh.material_override = mat
node.add_child(mesh)
var tag := Label3D.new()
tag.name = "tag"
tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag:723 不再拼串)
@@ -117,6 +208,23 @@ func _on_added(d: Dictionary) -> void:
node.set_meta("item_owner", owner)
node.set_meta("item_color", item_color)
_apply_owner_tag(node, owner, item_color)
if _is_rare_drop(vnum, d):
var beam := MeshInstance3D.new()
beam.name = "light_beam"
var cyl := CylinderMesh.new()
cyl.top_radius = 0.08
cyl.bottom_radius = 0.12
cyl.height = 3.5
beam.mesh = cyl
beam.position.y = 1.75
var bmat := StandardMaterial3D.new()
bmat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
bmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
bmat.albedo_color = Color(1.0, 0.85, 0.2, 0.45)
bmat.emission_enabled = true
bmat.emission = Color(1.0, 0.85, 0.3)
beam.material_override = bmat
node.add_child(beam)
mount.add_child(node)
_by_vid[vid] = node
@@ -214,49 +322,123 @@ func has_nearby_item() -> bool:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
return false
var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position
for node in _by_vid.values():
if is_instance_valid(node) and p.global_position.distance_to(node.global_position) < PICKUP_RANGE:
if not is_instance_valid(node):
continue
var n_pos: Vector3 = node.global_position if node.is_inside_tree() else node.position
if p_pos.distance_to(n_pos) < PICKUP_RANGE:
return true
return false
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
# 40250 1:1 PickCloseItem: 500ms 节流 + 距离 (步行 3m / 骑马 5m) + 所有权保护预检
func try_pickup() -> int:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null or client == null:
return 0
var now_ms := Time.get_ticks_msec()
if now_ms < _next_tcp_time_ms:
return 0
var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position
var best_vid := 0
var best_d := PICKUP_RANGE
var pick_dist := get_pickable_distance()
var best_d := pick_dist
var best_owner := ""
for vid in _by_vid:
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var d := p.global_position.distance_to(n.global_position)
var n_pos: Vector3 = n.global_position if n.is_inside_tree() else n.position
var d := p_pos.distance_to(n_pos)
if d < best_d:
best_d = d
best_vid = int(vid)
if best_vid != 0:
best_owner = String(n.get_meta("item_owner", ""))
if best_vid == 0:
return 0
var my_name := _get_main_player_name()
if best_owner != "" and my_name != "" and best_owner != my_name:
_on_cannot_pick_item(best_owner)
return 0
_next_tcp_time_ms = now_ms + 500
if client.has_method("pickup_item"):
client.pickup_item(best_vid)
return best_vid
func try_pickup_vid(vid: int) -> bool:
if client == null or not _by_vid.has(vid):
return false
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
return false
var now_ms := Time.get_ticks_msec()
if now_ms < _next_tcp_time_ms:
return false
var best_owner := String(n.get_meta("item_owner", ""))
var my_name := _get_main_player_name()
if best_owner != "" and my_name != "" and best_owner != my_name:
_on_cannot_pick_item(best_owner)
return false
_next_tcp_time_ms = now_ms + 500
if client.has_method("pickup_item"):
client.pickup_item(vid)
return true
# Mouse hover probe used by PlayerController's cursor state. Ground drops are
# deliberately tested in screen space, so clicking a visible label / mesh
# selects PICK even when the item is not the nearest drop in world distance.
func hover_at(camera: Camera3D, screen_pos: Vector2) -> bool:
return pick_at(camera, screen_pos) != 0
# Pick ground item at mouse screen position (40250 __Pick + __OnClickItem + CPythonTextTail::Pick).
# 同时支持点选地面模型、头顶名称标签(tag)与所有者标签(owner_tag)。
func pick_at(camera: Camera3D, screen_pos: Vector2) -> int:
if camera == null:
return false
var best := INF
for node in _by_vid.values():
return 0
var best_d := 64.0
var best_vid := 0
for vid in _by_vid:
var node: Node3D = _by_vid[vid]
if not is_instance_valid(node):
continue
if camera.is_position_behind(node.global_position):
continue
var p := camera.unproject_position(node.global_position + Vector3(0, 0.35, 0))
var d := p.distance_to(screen_pos)
if d <= 28.0:
best = minf(best, d)
return best < INF
var test_pts: Array[Vector3] = [
node.global_position + Vector3(0, 0.35, 0)
]
var tag: Node = node.get_node_or_null("tag")
if tag is Node3D and is_instance_valid(tag):
test_pts.append(tag.global_position)
var otag: Node = node.get_node_or_null("owner_tag")
if otag is Node3D and is_instance_valid(otag):
test_pts.append(otag.global_position)
for tp in test_pts:
if camera.is_position_behind(tp):
continue
var p := camera.unproject_position(tp)
var d := p.distance_to(screen_pos)
if d < best_d:
best_d = d
best_vid = int(vid)
return best_vid
func get_item_world_pos(vid: int) -> Variant:
var node: Node3D = _by_vid.get(vid, null)
if node and is_instance_valid(node):
return node.global_position if node.is_inside_tree() else node.position
return null
func _name_for(vnum: int) -> String:
if vnum == 1:
return "Yang"
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():