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
+243
View File
@@ -0,0 +1,243 @@
# safebox_storage_keeper_system.gd —— 40250 官方仓库保管员金条熔铸与密码保险箱系统 1:1
# 严格对照:
# metin2/src/server/game/src/safebox.h, safebox.cpp
# metin2/src/server/game/src/char.cpp:5569-5605 (ReqSafeboxLoad)
# metin2/src/server/game/src/input_db.cpp:1225-1250 (SafeboxWrongPassword / SafeboxChangePasswordAnswer)
# metin2/src/server/db/src/ClientManager.cpp:515-535 (RESULT_SAFEBOX_LOAD: default "000000")
# metin2/src/server/share/conf/item_proto.txt (80003~80007 金银条)
extends RefCounted
const SAFEBOX_MAX_NUM := 45 # 官方单页保险箱格数 (45 格)
const SAFEBOX_PASSWORD_MAX_LEN := 6 # 密码最长 6 位
const DEFAULT_PASSWORD := "000000" # 官方默认初始密码
const SAFEBOX_OPEN_DISTANCE := 1000 # 距离 NPC 9005 最大距离 1000
const SAFEBOX_COOLDOWN_SEC := 10 # 开箱冷却 10 秒 (PASSES_PER_SEC(10))
# 金银条规格表 (Vnum -> { "name": String, "face_value": int, "smelt_cost": int })
# 官方 1% 熔铸税 (cost = face_value * 1.01)
const GOLD_BAR_SPECS := {
80003: { "name": "银条 (5万)", "face_value": 50000, "smelt_cost": 50500 },
80004: { "name": "银条 (10万)", "face_value": 100000, "smelt_cost": 101000 },
80005: { "name": "金条 (50万)", "face_value": 500000, "smelt_cost": 505000 },
80006: { "name": "金条 (100万)", "face_value": 1000000, "smelt_cost": 1010000 },
80007: { "name": "金条 (200万)", "face_value": 2000000, "smelt_cost": 2020000 },
}
var password: String = DEFAULT_PASSWORD
var is_open: bool = false
var last_open_time: int = -100
var current_time: int = 1000
# 保险箱 45 格网格 (slot 0..44 -> Dictionary or null)
var safebox_grid: Array = []
func _init() -> void:
password = DEFAULT_PASSWORD
is_open = false
last_open_time = -100
current_time = 1000
safebox_grid.clear()
for i in range(SAFEBOX_MAX_NUM):
safebox_grid.append(null)
# 1. 密码校验并打开保险箱 (1:1 CHARACTER::ReqSafeboxLoad)
func open_safebox(entered_pw: String, npc_distance: float = 200.0) -> Dictionary:
var res := {
"success": false,
"error_code": "OK",
"message": ""
}
# 距离检查
if npc_distance > SAFEBOX_OPEN_DISTANCE:
res["error_code"] = "TOO_FAR"
res["message"] = "<仓库> 距离太远,无法打开仓库。"
return res
# 10秒防刷频冷却检查
if (current_time - last_open_time) < SAFEBOX_COOLDOWN_SEC:
res["error_code"] = "COOLDOWN_ACTIVE"
res["message"] = "<仓库> 仓库在 10 秒内只能打开一次。"
return res
# 密码长度检查
if entered_pw.is_empty() or entered_pw.length() > SAFEBOX_PASSWORD_MAX_LEN:
res["error_code"] = "INVALID_PASSWORD_LEN"
res["message"] = "<仓库> 密码格式错误,必须为 6 位字符以内。"
return res
# 密码匹配检查 (ClientManager.cpp:525)
if entered_pw != password:
res["error_code"] = "WRONG_PASSWORD"
res["message"] = "<仓库> 仓库密码错误!"
return res
# 打开成功
is_open = true
last_open_time = current_time
res["success"] = true
res["message"] = "<仓库> 仓库开启成功。"
return res
# 2. 修改仓库密码 (1:1 SafeboxChangePassword)
func change_password(old_pw: String, new_pw: String) -> Dictionary:
var res := {
"success": false,
"error_code": "OK",
"message": ""
}
if not is_open:
res["error_code"] = "SAFEBOX_NOT_OPEN"
res["message"] = "<仓库> 必须先开启仓库方可修改密码。"
return res
if old_pw != password:
res["error_code"] = "OLD_PASSWORD_MISMATCH"
res["message"] = "<仓库> 旧密码不匹配,修改失败。"
return res
if new_pw.is_empty() or new_pw.length() > SAFEBOX_PASSWORD_MAX_LEN:
res["error_code"] = "INVALID_NEW_PASSWORD"
res["message"] = "<仓库> 新密码长度不合法。"
return res
password = new_pw
res["success"] = true
res["message"] = "<仓库> 仓库密码已成功更改。"
return res
# 3. 关闭仓库
func close_safebox() -> void:
is_open = false
# 4. 存入物品 (Inventory -> Safebox)
func deposit_item(safebox_slot: int, item_data: Dictionary) -> Dictionary:
var res := { "success": false, "error_code": "OK", "message": "" }
if not is_open:
res["error_code"] = "NOT_OPEN"
return res
if safebox_slot < 0 or safebox_slot >= SAFEBOX_MAX_NUM:
res["error_code"] = "INVALID_SLOT"
return res
if safebox_grid[safebox_slot] != null:
# 尝试堆叠
var existing: Dictionary = safebox_grid[safebox_slot]
if existing.get("vnum") == item_data.get("vnum") and existing.get("stackable", false):
existing["count"] = existing.get("count", 1) + item_data.get("count", 1)
res["success"] = true
res["message"] = "物品成功堆叠存入仓库。"
return res
else:
res["error_code"] = "SLOT_OCCUPIED"
res["message"] = "目标仓库格位已被占用。"
return res
safebox_grid[safebox_slot] = item_data.duplicate(true)
res["success"] = true
res["message"] = "物品成功存入仓库。"
return res
# 5. 取出物品 (Safebox -> Inventory)
func withdraw_item(safebox_slot: int) -> Dictionary:
var res := { "success": false, "item": null, "error_code": "OK" }
if not is_open:
res["error_code"] = "NOT_OPEN"
return res
if safebox_slot < 0 or safebox_slot >= SAFEBOX_MAX_NUM:
res["error_code"] = "INVALID_SLOT"
return res
if safebox_grid[safebox_slot] == null:
res["error_code"] = "EMPTY_SLOT"
return res
var it: Dictionary = safebox_grid[safebox_slot]
safebox_grid[safebox_slot] = null
res["success"] = true
res["item"] = it
return res
# 6. 保险箱内格位互换/移动
func move_safebox_item(from_slot: int, to_slot: int) -> bool:
if not is_open:
return false
if from_slot < 0 or from_slot >= SAFEBOX_MAX_NUM or to_slot < 0 or to_slot >= SAFEBOX_MAX_NUM:
return false
if safebox_grid[from_slot] == null:
return false
var item_a = safebox_grid[from_slot]
var item_b = safebox_grid[to_slot]
if item_b != null and item_b.get("vnum") == item_a.get("vnum") and item_a.get("stackable", false):
item_b["count"] = item_b.get("count", 1) + item_a.get("count", 1)
safebox_grid[from_slot] = null
else:
safebox_grid[to_slot] = item_a
safebox_grid[from_slot] = item_b
return true
# 7. 金条/银条熔铸 (Player Gold -> Gold Bar Item)
# 仓库保管员按 1% 手续费收取金币并铸造成可储存/交易金条
func smelt_gold_bar(player_gold: int, bar_vnum: int) -> Dictionary:
var res := {
"success": false,
"new_gold": player_gold,
"bar_item": null,
"cost": 0,
"error_code": "OK",
"message": ""
}
if not GOLD_BAR_SPECS.has(bar_vnum):
res["error_code"] = "INVALID_BAR_VNUM"
res["message"] = "未知的金银条规格。"
return res
var spec: Dictionary = GOLD_BAR_SPECS[bar_vnum]
var cost: int = spec["smelt_cost"]
res["cost"] = cost
if player_gold < cost:
res["error_code"] = "INSUFFICIENT_GOLD"
res["message"] = "金币不足,铸造 %s 需支付 %d 金币 (含1%%熔铸税)。" % [spec["name"], cost]
return res
res["new_gold"] = player_gold - cost
res["bar_item"] = {
"vnum": bar_vnum,
"name": spec["name"],
"count": 1,
"face_value": spec["face_value"]
}
res["success"] = true
res["message"] = "成功熔铸 %s!扣除 %d 金币。" % [spec["name"], cost]
return res
# 8. 金条/银条承兑支取现金 (Gold Bar Item -> Player Gold)
# 承兑取回票面全额金币 (1:1 face_value)
func encash_gold_bar(player_gold: int, bar_vnum: int) -> Dictionary:
var res := {
"success": false,
"new_gold": player_gold,
"cash_value": 0,
"error_code": "OK",
"message": ""
}
if not GOLD_BAR_SPECS.has(bar_vnum):
res["error_code"] = "INVALID_BAR_VNUM"
res["message"] = "非官方认证金银条,无法承兑。"
return res
var spec: Dictionary = GOLD_BAR_SPECS[bar_vnum]
var face_val: int = spec["face_value"]
res["cash_value"] = face_val
res["new_gold"] = player_gold + face_val
res["success"] = true
res["message"] = "成功承兑 %s,兑现出金币 %d" % [spec["name"], face_val]
return res