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:
+208
-26
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user