Files
mtgodot-poc/project/ui/ground_items.gd
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

512 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GroundItems (P2) —— 世界里的掉落物:GC_ITEM_GROUND_ADD/DEL → 小物体 + 名条 + 拾取。
#
# var gi := preload("res://ui/ground_items.gd").new()
# add_child(gi)
# gi.setup(m2client, mount_node, player_getter, proto, item_list)
# # 按拾取键时: gi.try_pickup() -> 捡最近的一个(<PICKUP_RANGE m
#
# 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):多条掉落
# 名条投影到屏幕后互相挤开防重叠。参考端在屏幕像素空间跑(y 向下),poc 每物一个
# billboard Label3D —— 把名条世界锚点投影到屏幕、按 Label3D 的真实字体度量取文本框,跑
# text_tail_arrange.arrange_item_tails 得到每条被下推的屏幕 y,再按 Label3D 的
# pixel_size 折回世界 y(屏幕向下 Δpx → 世界向下 Δpx*pixel_size)。
# 文本框宽 / 高取名条 Label3D 的真实字体度量(TextMetrics.label_width / label_height
# = GetTextSize 的 w / h1:1,增量 123 / 124TAG_CHAR_W_PX / TAG_LINE_H_PX 仅作无字体兜底)。
# 增量 125SetItemTextTailOwner, PythonTextTail.cpp:706-737):有 owner 的掉落,参考端
# owner 是与名字分开的 pOwnerTextInstance,单独 GetTextSize,令去重叠框 yEnd += ownerH + 4、
# x 两边按 owner 半宽 ±1 外扩 —— 去重叠框把「名字」「owner + 所有格」两子串分别用 tag 的
# 字体度量后走 TextTailArrange.item_box_with_owner 合成(框度量 1:1)。
# 增量 126SetItemTextTailOwner :712-745 + ArrangeTextTail :191-207):owner 后缀不再拼进
# 名字串,而是 drop 节点下第二个 Label3D "owner_tag" —— CENTER 对齐、黄字 (1,1,0)、文本
# = owner + strOwnership:722,本地化语素为空时字面 "'s",经 setup(possessive) 注入);
# owner 落在尾标基点、名字实例下移 15px:200 pTextInstance->SetPosition(x, y + 15.0f, z));
# owner 为空时删 owner_tag:741-745)。名字色仍走 _process 远近着色(参考名字实例取
# pInsertTextTail->Color 的道具品阶色)。
const TextTailArrange = preload("res://text_tail_arrange.gd")
const TextMetrics = preload("res://text_metrics.gd")
const TAG_BASE_Y := 0.75 # 名条相对掉落物的默认局部 y(未重叠时)
const TAG_CHAR_W_PX := 7.0 # 无字体时的每字符估宽兜底(有字体走 GetTextSize 的 w
const TAG_LINE_H_PX := 15.0 # 无字体时的单行估高兜底(有字体走 GetTextSize 的 h
const OWNER_POSSESSIVE := "'s" # SetItemTextTailOwner :722 strOwnershipIDS_POSSESSIVE_MORPHENE 为空时的字面值)
const OWNER_NAME_GAP_PX := 15.0 # ArrangeTextTail :200 pTextInstance->SetPosition(x, y + 15.0f, z)owner 行下方的名字行偏移
const DEFAULT_ITEM_COLOR := Color(1.0, 0.9, 0.5)
const ITEM_GRADE_COLORS := {
0: Color(1.0, 1.0, 1.0),
1: Color(0.55, 0.85, 1.0),
2: Color(1.0, 0.85, 0.25),
3: Color(0.85, 0.45, 1.0),
4: Color(1.0, 0.35, 0.35),
}
var client: Node
var mount: Node3D
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 := "",
world_getter: Callable = Callable()) -> void:
client = m2client
_possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc
mount = mount_node
proto = proto_node
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"):
client.ground_item_removed.connect(_on_removed)
# 已在场的(重连)
if client.has_method("get_ground_items"):
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:
return
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
existing.set_meta("item_name", existing_tag.text)
existing.set_meta("item_owner", owner)
existing.set_meta("item_color", item_color)
_apply_owner_tag(existing, owner, item_color)
return
var node := Node3D.new()
node.name = "drop_%d" % vid
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 不再拼串)
tag.position.y = TAG_BASE_Y
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.005
tag.modulate = item_color
node.add_child(tag)
node.set_meta("vid", vid)
node.set_meta("item_name", tag.text)
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
func _on_removed(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
n.queue_free()
func clear_for_map_change() -> void:
for vid in _by_vid.keys():
var node: Node3D = _by_vid[vid]
if is_instance_valid(node):
node.queue_free()
_by_vid.clear()
func _process(dt: float) -> void:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
return
var t := Time.get_ticks_msec() / 1000.0
for vid in _by_vid:
var n: Node3D = _by_vid[vid]
if is_instance_valid(n):
var m := n.get_child(0) as MeshInstance3D
if m:
m.rotation.y = t * 2.0
# 近了名条高亮
var near := p.global_position.distance_to(n.global_position) <= PICKUP_RANGE
(n.get_node("tag") as Label3D).modulate = Color(0.4, 1.0, 0.4) if near else Color(1.0, 0.9, 0.5)
_arrange_tags()
# ArrangeTextTail :153-190 的 item 块:把所有掉落名条投影到屏幕,跑 isIn 去重叠,
# 再把被下推的屏幕 Δy 折回各 Label3D 的世界局部 y。无相机 / 少于 2 条时全部回默认。
func _arrange_tags() -> void:
var cam: Camera3D = _camera_getter.call() if _camera_getter.is_valid() else null
if cam == null:
return
# m_ItemTextTailList 顺序:按 vid 排序保证稳定(≈ 注册先后)。
var vids: Array = _by_vid.keys()
vids.sort()
var tags: Array = [] # 与 boxes 平行:{tag, base_screen_y}
var boxes: Array = []
for vid in vids:
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var tag := n.get_node_or_null("tag") as Label3D
if tag == null:
continue
var anchor := n.global_position + Vector3(0, TAG_BASE_Y, 0)
if cam.is_position_behind(anchor):
_place_tail(n, tag, 0.0)
continue
var sp := cam.unproject_position(anchor)
tags.append({"n": n, "tag": tag, "y0": sp.y})
# 名字子串 + owner 子串分开度量(SetItemTextTailOwner 的 pOwnerTextInstance),
# 再走 item_box_with_owner 合成去重叠框(yEnd += ownerH + 4)。
var iname := String(n.get_meta("item_name", tag.text))
var iowner := String(n.get_meta("item_owner", ""))
var owner_txt := (iowner + _possessive) if iowner != "" else ""
var nbox: Dictionary = TextTailArrange.item_box_with_owner(
TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX),
TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX),
TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX),
TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX))
boxes.append({
"x": sp.x, "y": sp.y,
"w": maxf(1.0, nbox["w"]),
"h": maxf(1.0, nbox["h"]),
})
if boxes.size() < 2:
if boxes.size() == 1:
_place_tail(tags[0]["n"], tags[0]["tag"], 0.0)
return
var arranged: Array = TextTailArrange.arrange_item_tails(boxes)
for i in tags.size():
var d_px: float = arranged[i] - float(tags[i]["y0"]) # 屏幕向下为正
_place_tail(tags[i]["n"], tags[i]["tag"], d_px)
# ArrangeTextTail :191-207owner 存在时 owner 实例落在尾标基点 (x, y)、名字实例下移 15px
# :200 pTextInstance->SetPosition(x, y + 15.0f, z));无 owner 时名字实例落在基点 (:206)。
# d_px = 去重叠把该尾标下推的屏幕像素(0 = 未被推)。屏幕向下 → 世界向下(* pixel_size)。
func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void:
var px: float = tag.pixel_size
var owner_tag := n.get_node_or_null("owner_tag") as Label3D
if owner_tag != null:
owner_tag.position.y = TAG_BASE_Y - d_px * px
tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX) * px
else:
tag.position.y = TAG_BASE_Y - d_px * px
# 移动端情境按钮用它判断附近是否有可拾取物,避免常驻占用 HUD。
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 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
# 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 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 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)
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 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 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():
return String(pd.get("locale_name", pd.get("name", "item %d" % vnum)))
if item_list and item_list.has(vnum):
return item_list.type_of(vnum)
return "item %d" % vnum
# SetItemTextTailOwner :712-745owner 非空 → 一个独立的 CENTER 黄字 (1,1,0) text instance
# 文本 = owner + strOwnership:722/:723);owner 为空 → 删该实例(:741-745)。
# pocdrop 节点下第二个 Label3D "owner_tag"(渲染顺序在名字之后,:373)。
func _apply_owner_tag(node: Node3D, owner: String, item_color := DEFAULT_ITEM_COLOR) -> void:
var owner_tag := node.get_node_or_null("owner_tag") as Label3D
if owner.is_empty():
if owner_tag != null:
node.remove_child(owner_tag)
owner_tag.queue_free()
return
if owner_tag == null:
owner_tag = Label3D.new()
owner_tag.name = "owner_tag"
owner_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # :727 HORIZONTAL_ALIGN_CENTER
owner_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
owner_tag.no_depth_test = true
owner_tag.pixel_size = 0.005
owner_tag.position.y = TAG_BASE_Y
node.add_child(owner_tag)
owner_tag.modulate = item_color # :729 的 pInsertTextTail->Color
owner_tag.text = owner + _possessive
func _item_color(vnum: int, drop: Dictionary) -> Color:
# GC_ITEM_GROUND_ADD 只带 vnum/owner/pos;优先消费服务端或 fixture 显式携带
# 的颜色,其次消费 item proto 的扩展字段。老数据没有品阶字段时保留旧的
# 金色 fallback,而不会把 owner 行错误固定成纯黄。
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
for source in [drop, pd]:
for key in ["item_color", "rarity_color", "name_color", "color"]:
if source.has(key):
var parsed: Variant = _coerce_color(source[key])
if parsed != null:
return parsed
for key in ["item_grade", "rarity", "quality", "grade"]:
if source.has(key):
var grade := int(source[key])
if ITEM_GRADE_COLORS.has(grade):
return ITEM_GRADE_COLORS[grade]
return DEFAULT_ITEM_COLOR
func _coerce_color(value: Variant) -> Variant:
if value is Color:
return value
if value is Array and value.size() >= 3:
var scale := 255.0 if float(value[0]) > 1.0 or float(value[1]) > 1.0 \
or float(value[2]) > 1.0 else 1.0
var alpha := float(value[3]) if value.size() > 3 else 1.0
if alpha > 1.0:
alpha /= 255.0
return Color(float(value[0]) / scale, float(value[1]) / scale,
float(value[2]) / scale, alpha)
if value is Dictionary and value.has_all(["r", "g", "b"]):
var scale := 255.0 if float(value["r"]) > 1.0 or float(value["g"]) > 1.0 \
or float(value["b"]) > 1.0 else 1.0
var alpha := float(value.get("a", 1.0))
if alpha > 1.0:
alpha /= 255.0
return Color(float(value["r"]) / scale, float(value["g"]) / scale,
float(value["b"]) / scale, alpha)
if value is String and not String(value).strip_edges().is_empty():
return Color.from_string(String(value), DEFAULT_ITEM_COLOR)
return null