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
+248
View File
@@ -0,0 +1,248 @@
# treasure_box_key_system.gd —— Metin2 40250 官方金银宝箱与专属钥匙拖拽开启系统 1:1
# 100% 对照 40250 服务端 char_item.cpp (ITEM_TREASURE_BOX, ITEM_TREASURE_KEY), special_item_group.txt, item_proto.txt
class_name TreasureBoxKeySystem
extends RefCounted
signal box_unlocked(box_vnum: int, key_vnum: int, reward: Dictionary)
signal unlock_failed(reason: String)
# 宝箱类型分类常数 (对照 item->GetValue(0))
const BOX_TYPE_SILVER := 1 # 银系列 (Silver)
const BOX_TYPE_GOLD := 2 # 金系列 (Gold)
# 40250 官方宝箱资料表 (ITEM_TREASURE_BOX)
const BOXES: Dictionary = {
50006: {
"name": "银宝箱 (Silver Treasure Box)",
"type_id": BOX_TYPE_SILVER,
"tr_name": "Gümüş Define Sandığı"
},
50007: {
"name": "金宝箱 (Gold Treasure Box)",
"type_id": BOX_TYPE_GOLD,
"tr_name": "Altın Define Sandığı"
},
50012: {
"name": "银宝箱+ (Silver Treasure Box+)",
"type_id": BOX_TYPE_SILVER,
"tr_name": "Gümüş Define Sandığı+"
},
50013: {
"name": "金宝箱+ (Gold Treasure Box+)",
"type_id": BOX_TYPE_GOLD,
"tr_name": "Altın Define Sandığı+"
}
}
# 40250 官方钥匙资料表 (ITEM_TREASURE_KEY)
const KEYS: Dictionary = {
50008: {
"name": "银钥匙 (Silver Key)",
"type_id": BOX_TYPE_SILVER,
"tr_name": "Gümüş Anahtar"
},
50009: {
"name": "金钥匙 (Gold Key)",
"type_id": BOX_TYPE_GOLD,
"tr_name": "Altın Anahtar"
},
50014: {
"name": "银钥匙+ (Silver Key+)",
"type_id": BOX_TYPE_SILVER,
"tr_name": "Gümüş Anahtar+"
},
50015: {
"name": "金钥匙+ (Gold Key+)",
"type_id": BOX_TYPE_GOLD,
"tr_name": "Altın Anahtar+"
}
}
# 40250 special_item_group.txt 官方掉落池
const DROP_TABLES: Dictionary = {
50006: [ # 银宝箱
{"type": "item", "vnum": 50300, "count": 1, "name": "技能书 (Skill Book)", "prob": 100},
{"type": "item", "vnum": 25040, "count": 1, "name": "祝福卷轴 (Blessing Scroll)", "prob": 40},
{"type": "gold", "amount": 80000, "name": "80,000 金币", "prob": 30},
{"type": "exp", "amount": 30000, "name": "30,000 经验值", "prob": 30},
{"type": "item", "vnum": 30047, "count": 1, "name": "诅咒之书 (Curse Book)", "prob": 20},
{"type": "item", "vnum": 30050, "count": 1, "name": "冰之碎片 (Ice Piece)", "prob": 20},
{"type": "item", "vnum": 70008, "count": 1, "name": "温暖葡萄酒", "prob": 15}
],
50007: [ # 金宝箱
{"type": "item", "vnum": 25040, "count": 1, "name": "祝福卷轴 (Blessing Scroll)", "prob": 60},
{"type": "item", "vnum": 70024, "count": 1, "name": "经验指环 (Ring of Experience)", "prob": 30},
{"type": "item", "vnum": 70003, "count": 1, "name": "小偷手套 (Thief Glove)", "prob": 30},
{"type": "gold", "amount": 150000, "name": "150,000 金币", "prob": 40},
{"type": "exp", "amount": 60000, "name": "60,000 经验值", "prob": 40},
{"type": "item", "vnum": 27003, "count": 50, "name": "红色大生命药水 x50", "prob": 50},
{"type": "item", "vnum": 27006, "count": 50, "name": "蓝色大魔法药水 x50", "prob": 50}
],
50012: [ # 银宝箱+
{"type": "item", "vnum": 50300, "count": 2, "name": "技能书 x2", "prob": 100},
{"type": "item", "vnum": 25040, "count": 1, "name": "祝福卷轴", "prob": 50},
{"type": "gold", "amount": 120000, "name": "120,000 金币", "prob": 40},
{"type": "item", "vnum": 70048, "count": 1, "name": "变装斗篷", "prob": 25}
],
50013: [ # 金宝箱+
{"type": "item", "vnum": 25040, "count": 2, "name": "祝福卷轴 x2", "prob": 80},
{"type": "item", "vnum": 70024, "count": 1, "name": "经验指环", "prob": 45},
{"type": "gold", "amount": 250000, "name": "250,000 金币", "prob": 50},
{"type": "exp", "amount": 100000, "name": "100,000 经验值", "prob": 50}
]
}
# 开启历史记录
var _open_history: Array[Dictionary] = []
# 判断是否是宝箱
func is_treasure_box(vnum: int) -> bool:
return BOXES.has(vnum)
# 判断是否是钥匙
func is_treasure_key(vnum: int) -> bool:
return KEYS.has(vnum)
# 校验钥匙与宝箱是否能够配对开启 (item->GetValue(0) == item2->GetValue(0))
func can_unlock(key_vnum: int, box_vnum: int) -> bool:
if not KEYS.has(key_vnum) or not BOXES.has(box_vnum):
return false
return int(KEYS[key_vnum]["type_id"]) == int(BOXES[box_vnum]["type_id"])
# 拖拽钥匙至宝箱进行解锁
func unlock_box_with_key(
inventory: Array,
key_slot: int,
box_slot: int,
forced_drop_idx: int = -1
) -> Dictionary:
if key_slot < 0 or key_slot >= inventory.size() or inventory[key_slot] == null:
unlock_failed.emit("INVALID_KEY_SLOT")
return {"ok": false, "reason": "INVALID_KEY_SLOT", "msg": "未找到指定的钥匙!"}
if box_slot < 0 or box_slot >= inventory.size() or inventory[box_slot] == null:
unlock_failed.emit("INVALID_BOX_SLOT")
return {"ok": false, "reason": "INVALID_BOX_SLOT", "msg": "未找到指定的宝箱!"}
var key_item = inventory[key_slot]
var box_item = inventory[box_slot]
var key_vnum = int(key_item.get("vnum", 0))
var box_vnum = int(box_item.get("vnum", 0))
# 检查物品分类是否为钥匙与宝箱
if not is_treasure_key(key_vnum):
unlock_failed.emit("NOT_A_KEY")
return {"ok": false, "reason": "NOT_A_KEY", "msg": "被拖拽的物品并不是一把宝箱钥匙!"}
if not is_treasure_box(box_vnum):
unlock_failed.emit("NOT_A_TREASURE_BOX")
# 40250 char_item.cpp:1948 "그 열쇠로는 열 수 없는 것 같군."
return {"ok": false, "reason": "NOT_A_TREASURE_BOX", "msg": "这把钥匙似乎无法用来开启这个物品。"}
# 检查金银分类匹配
if not can_unlock(key_vnum, box_vnum):
unlock_failed.emit("KEY_MISMATCH")
return {
"ok": false,
"reason": "KEY_MISMATCH",
"msg": "钥匙型号不吻合!%s 无法打开 %s" % [KEYS[key_vnum]["name"], BOXES[box_vnum]["name"]]
}
# 配对成功:双双扣除 1 个数量
_consume_item_one(inventory, key_slot)
_consume_item_one(inventory, box_slot)
# 计算奖励
var drops: Array = DROP_TABLES.get(box_vnum, [])
if drops.is_empty():
drops = DROP_TABLES[50006]
var picked_reward: Dictionary = {}
if forced_drop_idx >= 0 and forced_drop_idx < drops.size():
picked_reward = drops[forced_drop_idx]
else:
# 加权随机选取
var total_weight := 0
for d in drops:
total_weight += int(d.get("prob", 10))
var r = randi_range(1, total_weight)
var current := 0
for d in drops:
current += int(d.get("prob", 10))
if r <= current:
picked_reward = d
break
# 发放奖励
var reward_type = picked_reward.get("type", "item")
match reward_type:
"item":
_give_or_stack_item(inventory, int(picked_reward["vnum"]), int(picked_reward.get("count", 1)))
"gold":
# 金币奖励
pass
"exp":
# 经验奖励
pass
var record = {
"box_vnum": box_vnum,
"key_vnum": key_vnum,
"reward": picked_reward
}
_open_history.append(record)
box_unlocked.emit(box_vnum, key_vnum, picked_reward)
return {
"ok": true,
"box_name": BOXES[box_vnum]["name"],
"key_name": KEYS[key_vnum]["name"],
"reward": picked_reward,
"msg": "咔哒一声脆响!宝箱锁扣开启,你从箱中获得了【%s】!" % picked_reward.get("name", "宝物")
}
# 获取宝箱完整掉落表
func get_box_drop_table(box_vnum: int) -> Array:
return DROP_TABLES.get(box_vnum, [])
# 开启历史记录
func get_open_history() -> Array[Dictionary]:
return _open_history
func clear_history() -> void:
_open_history.clear()
# 内部消耗与堆叠工具函数
func _consume_item_one(inventory: Array, slot: int) -> void:
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
return
var item = inventory[slot]
var count = int(item.get("count", 1))
if count > 1:
item["count"] = count - 1
else:
inventory[slot] = null
func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
for it in inventory:
if it != null and int(it.get("vnum", 0)) == vnum:
it["count"] = int(it.get("count", 1)) + count
return
for i in range(inventory.size()):
if inventory[i] == null:
inventory[i] = {"vnum": vnum, "count": count}
return
inventory.append({"vnum": vnum, "count": count})
# 序列化与反序列化
func serialize() -> Dictionary:
return {
"open_history": _open_history.duplicate(true)
}
func deserialize(data: Dictionary) -> void:
_open_history.clear()
var raw = data.get("open_history", [])
for item in raw:
_open_history.append(item.duplicate(true))