- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
344 lines
10 KiB
GDScript
344 lines
10 KiB
GDScript
# player_trade_exchange_system.gd —— 40250 官方玩家面对面安全交易双向锁定系统 1:1
|
|
# 严格对照:
|
|
# metin2/src/server/game/src/exchange.h
|
|
# metin2/src/server/game/src/exchange.cpp
|
|
# metin2/src/server/game/src/char.cpp (OpenExchange, SetExchange, etc.)
|
|
extends RefCounted
|
|
|
|
const EXCHANGE_ITEM_MAX_NUM := 12 # 官方交易面板 12 格 (4x3 布局)
|
|
const EXCHANGE_MAX_DISTANCE := 1000 # 官方交易最大距离 1000
|
|
|
|
# 玩家交易单边状态
|
|
class TradeSide:
|
|
var player_id: int
|
|
var player_name: String
|
|
var is_ready: bool = false
|
|
var offered_gold: int = 0
|
|
# 12 个格位: display_pos (0..11) -> item_data Dictionary or null
|
|
var display_slots: Array = []
|
|
# 关联背包位置: display_pos -> inv_slot
|
|
var inv_positions: Dictionary = {}
|
|
|
|
func _init(id: int, name: String) -> void:
|
|
player_id = id
|
|
player_name = name
|
|
is_ready = false
|
|
offered_gold = 0
|
|
display_slots.clear()
|
|
for i in range(EXCHANGE_ITEM_MAX_NUM):
|
|
display_slots.append(null)
|
|
inv_positions.clear()
|
|
|
|
func count_items() -> int:
|
|
var c := 0
|
|
for it in display_slots:
|
|
if it != null:
|
|
c += 1
|
|
return c
|
|
|
|
var side_a: TradeSide = null
|
|
var side_b: TradeSide = null
|
|
var is_active: bool = false
|
|
|
|
# 模拟玩家的背包与金币引用(便于原子化结算)
|
|
# player_data = { "id": int, "name": int, "gold": int, "inv": Array, "max_inv_slots": int }
|
|
var player_a_ref: Dictionary = {}
|
|
var player_b_ref: Dictionary = {}
|
|
|
|
func _init() -> void:
|
|
side_a = null
|
|
side_b = null
|
|
is_active = false
|
|
|
|
# 开启双向交易会话 (1:1 OpenExchange)
|
|
func start_exchange(player_a: Dictionary, player_b: Dictionary, distance: float = 200.0) -> Dictionary:
|
|
var res := { "success": false, "error_code": "OK", "message": "" }
|
|
|
|
if distance > EXCHANGE_MAX_DISTANCE:
|
|
res["error_code"] = "TOO_FAR"
|
|
res["message"] = "双方距离太远,无法进行交易。"
|
|
return res
|
|
|
|
if player_a.get("id") == player_b.get("id"):
|
|
res["error_code"] = "CANNOT_TRADE_SELF"
|
|
res["message"] = "无法与自己进行交易。"
|
|
return res
|
|
|
|
# 检查状态
|
|
if player_a.get("is_dead", false) or player_b.get("is_dead", false):
|
|
res["error_code"] = "PLAYER_DEAD"
|
|
res["message"] = "处于死亡状态的玩家无法交易。"
|
|
return res
|
|
|
|
if player_a.get("is_in_safebox", false) or player_b.get("is_in_safebox", false) \
|
|
or player_a.get("is_in_shop", false) or player_b.get("is_in_shop", false):
|
|
res["error_code"] = "BUSY_WINDOW"
|
|
res["message"] = "一方正处于其他交互窗口中,无法交易。"
|
|
return res
|
|
|
|
player_a_ref = player_a
|
|
player_b_ref = player_b
|
|
side_a = TradeSide.new(player_a["id"], player_a.get("name", "PlayerA"))
|
|
side_b = TradeSide.new(player_b["id"], player_b.get("name", "PlayerB"))
|
|
is_active = true
|
|
|
|
res["success"] = true
|
|
res["message"] = "交易窗口已开启。"
|
|
return res
|
|
|
|
# 获取玩家所属的交易边
|
|
func _get_side(player_id: int) -> TradeSide:
|
|
if side_a != null and side_a.player_id == player_id:
|
|
return side_a
|
|
elif side_b != null and side_b.player_id == player_id:
|
|
return side_b
|
|
return null
|
|
|
|
# 获取交易对手边
|
|
func _get_other_side(player_id: int) -> TradeSide:
|
|
if side_a != null and side_a.player_id == player_id:
|
|
return side_b
|
|
elif side_b != null and side_b.player_id == player_id:
|
|
return side_a
|
|
return null
|
|
|
|
func _get_player_ref(player_id: int) -> Dictionary:
|
|
if player_a_ref.get("id") == player_id:
|
|
return player_a_ref
|
|
elif player_b_ref.get("id") == player_id:
|
|
return player_b_ref
|
|
return {}
|
|
|
|
# 放入物品到交易栏 (1:1 CExchange::AddItem)
|
|
# 核心防调包规则:一旦修改物品,双方锁定状态立即重置为 false!
|
|
func add_item(player_id: int, inv_slot: int, display_pos: int) -> Dictionary:
|
|
var res := { "success": false, "error_code": "OK", "message": "" }
|
|
if not is_active:
|
|
res["error_code"] = "TRADE_NOT_ACTIVE"
|
|
return res
|
|
|
|
var side := _get_side(player_id)
|
|
var other := _get_other_side(player_id)
|
|
var player_data := _get_player_ref(player_id)
|
|
if side == null or other == null or player_data.is_empty():
|
|
res["error_code"] = "INVALID_PARTICIPANT"
|
|
return res
|
|
|
|
if display_pos < 0 or display_pos >= EXCHANGE_ITEM_MAX_NUM:
|
|
res["error_code"] = "INVALID_DISPLAY_POS"
|
|
return res
|
|
|
|
if side.display_slots[display_pos] != null:
|
|
res["error_code"] = "SLOT_ALREADY_OCCUPIED"
|
|
return res
|
|
|
|
var inv: Array = player_data.get("inv", [])
|
|
if inv_slot < 0 or inv_slot >= inv.size() or inv[inv_slot] == null:
|
|
res["error_code"] = "ITEM_NOT_FOUND"
|
|
return res
|
|
|
|
var it: Dictionary = inv[inv_slot]
|
|
|
|
# 1:1 防交易检查: ANTI_GIVE 标记 / 灵魂绑定 / 装备中
|
|
if it.get("anti_give", false) or it.get("is_soulbound", false) or it.get("is_equipped", false):
|
|
res["error_code"] = "ITEM_UNTRADEABLE"
|
|
res["message"] = "该物品受法则限制,不可交易或转移。"
|
|
return res
|
|
|
|
# 放入交易槽
|
|
side.display_slots[display_pos] = it.duplicate(true)
|
|
side.inv_positions[display_pos] = inv_slot
|
|
|
|
# 40250 官方核心安全机制: 重置双方锁定状态!
|
|
side.is_ready = false
|
|
other.is_ready = false
|
|
|
|
res["success"] = true
|
|
res["message"] = "物品已上架交易栏,双方就绪状态已重置。"
|
|
return res
|
|
|
|
# 从交易栏取回物品 (1:1 CExchange::RemoveItem)
|
|
# 同样重置双方锁定状态!
|
|
func remove_item(player_id: int, display_pos: int) -> Dictionary:
|
|
var res := { "success": false, "error_code": "OK" }
|
|
if not is_active:
|
|
res["error_code"] = "TRADE_NOT_ACTIVE"
|
|
return res
|
|
|
|
var side := _get_side(player_id)
|
|
var other := _get_other_side(player_id)
|
|
if side == null or other == null:
|
|
res["error_code"] = "INVALID_PARTICIPANT"
|
|
return res
|
|
|
|
if display_pos < 0 or display_pos >= EXCHANGE_ITEM_MAX_NUM or side.display_slots[display_pos] == null:
|
|
res["error_code"] = "SLOT_EMPTY"
|
|
return res
|
|
|
|
side.display_slots[display_pos] = null
|
|
side.inv_positions.erase(display_pos)
|
|
|
|
# 40250 官方安全机制: 重置双方锁定状态!
|
|
side.is_ready = false
|
|
other.is_ready = false
|
|
|
|
res["success"] = true
|
|
return res
|
|
|
|
# 设置出价金币 (1:1 CExchange::AddGold)
|
|
# 同样重置双方锁定状态!
|
|
func set_gold(player_id: int, amount: int) -> Dictionary:
|
|
var res := { "success": false, "error_code": "OK", "message": "" }
|
|
if not is_active:
|
|
res["error_code"] = "TRADE_NOT_ACTIVE"
|
|
return res
|
|
|
|
var side := _get_side(player_id)
|
|
var other := _get_other_side(player_id)
|
|
var player_data := _get_player_ref(player_id)
|
|
if side == null or other == null or player_data.is_empty():
|
|
res["error_code"] = "INVALID_PARTICIPANT"
|
|
return res
|
|
|
|
if amount < 0:
|
|
res["error_code"] = "INVALID_GOLD"
|
|
return res
|
|
|
|
if player_data.get("gold", 0) < amount:
|
|
res["error_code"] = "INSUFFICIENT_GOLD"
|
|
res["message"] = "身上金币不足,无法出价。"
|
|
return res
|
|
|
|
side.offered_gold = amount
|
|
|
|
# 40250 官方安全机制: 重置双方锁定状态!
|
|
side.is_ready = false
|
|
other.is_ready = false
|
|
|
|
res["success"] = true
|
|
res["message"] = "出价金额已更新,双方就绪状态已重置。"
|
|
return res
|
|
|
|
# 点击锁定/同意 (1:1 CExchange::Accept)
|
|
# 双方均锁定后自动触发原子化结算
|
|
func set_ready(player_id: int, ready_state: bool) -> Dictionary:
|
|
var res := {
|
|
"success": false,
|
|
"trade_completed": false,
|
|
"error_code": "OK",
|
|
"message": ""
|
|
}
|
|
if not is_active:
|
|
res["error_code"] = "TRADE_NOT_ACTIVE"
|
|
return res
|
|
|
|
var side := _get_side(player_id)
|
|
var other := _get_other_side(player_id)
|
|
if side == null or other == null:
|
|
res["error_code"] = "INVALID_PARTICIPANT"
|
|
return res
|
|
|
|
side.is_ready = ready_state
|
|
res["success"] = true
|
|
|
|
# 若双方均已 Ready 锁定,执行原子化交付完成交易 (CExchange::Done)
|
|
if side_a.is_ready and side_b.is_ready:
|
|
var done_res = _execute_atomic_trade()
|
|
if done_res["success"]:
|
|
res["trade_completed"] = true
|
|
res["message"] = "交易成功!双方物品与金币已安全过户。"
|
|
else:
|
|
res["trade_completed"] = false
|
|
res["error_code"] = done_res["error_code"]
|
|
res["message"] = done_res["message"]
|
|
else:
|
|
res["message"] = "已就绪锁定,等待对方确认。"
|
|
|
|
return res
|
|
|
|
# 背包剩余空位计算
|
|
func _get_free_inv_slots(player_data: Dictionary) -> int:
|
|
var inv: Array = player_data.get("inv", [])
|
|
var free_count := 0
|
|
for it in inv:
|
|
if it == null:
|
|
free_count += 1
|
|
return free_count
|
|
|
|
# 原子化事务交付执行 (1:1 CExchange::Done & CheckSpace)
|
|
func _execute_atomic_trade() -> Dictionary:
|
|
var res := { "success": false, "error_code": "OK", "message": "" }
|
|
|
|
# 1. 再次校验金币是否充足
|
|
if player_a_ref.get("gold", 0) < side_a.offered_gold:
|
|
res["error_code"] = "PLAYER_A_LESS_GOLD"
|
|
res["message"] = "一方金币不足,交易终止。"
|
|
return res
|
|
if player_b_ref.get("gold", 0) < side_b.offered_gold:
|
|
res["error_code"] = "PLAYER_B_LESS_GOLD"
|
|
res["message"] = "一方金币不足,交易终止。"
|
|
return res
|
|
|
|
# 2. 空间校验 (CheckSpace):
|
|
# 玩家 A 需容纳 B 给予的所有物品;玩家 B 需容纳 A 给予的所有物品
|
|
# 考虑双方移出物品后腾出的背包空位
|
|
var a_items_count := side_a.count_items()
|
|
var b_items_count := side_b.count_items()
|
|
|
|
var a_free := _get_free_inv_slots(player_a_ref) + a_items_count
|
|
var b_free := _get_free_inv_slots(player_b_ref) + b_items_count
|
|
|
|
if a_free < b_items_count:
|
|
res["error_code"] = "PLAYER_A_INSUFFICIENT_SPACE"
|
|
res["message"] = "玩家 A 背包空间不足,无法接收对方物品。"
|
|
return res
|
|
|
|
if b_free < a_items_count:
|
|
res["error_code"] = "PLAYER_B_INSUFFICIENT_SPACE"
|
|
res["message"] = "玩家 B 背包空间不足,无法接收对方物品。"
|
|
return res
|
|
|
|
# 3. 执行金币转移
|
|
player_a_ref["gold"] = player_a_ref.get("gold", 0) - side_a.offered_gold + side_b.offered_gold
|
|
player_b_ref["gold"] = player_b_ref.get("gold", 0) - side_b.offered_gold + side_a.offered_gold
|
|
|
|
# 4. 执行物品转移
|
|
# 从 A 背包移除 A 出售的物品
|
|
var inv_a: Array = player_a_ref.get("inv", [])
|
|
for dpos in side_a.inv_positions:
|
|
var inv_slot: int = side_a.inv_positions[dpos]
|
|
inv_a[inv_slot] = null
|
|
|
|
# 从 B 背包移除 B 出售的物品
|
|
var inv_b: Array = player_b_ref.get("inv", [])
|
|
for dpos in side_b.inv_positions:
|
|
var inv_slot: int = side_b.inv_positions[dpos]
|
|
inv_b[inv_slot] = null
|
|
|
|
# 将 A 的物品放入 B 的背包空位
|
|
for it in side_a.display_slots:
|
|
if it != null:
|
|
for i in range(inv_b.size()):
|
|
if inv_b[i] == null:
|
|
inv_b[i] = it.duplicate(true)
|
|
break
|
|
|
|
# 将 B 的物品放入 A 的背包空位
|
|
for it in side_b.display_slots:
|
|
if it != null:
|
|
for i in range(inv_a.size()):
|
|
if inv_a[i] == null:
|
|
inv_a[i] = it.duplicate(true)
|
|
break
|
|
|
|
# 5. 关闭会话
|
|
is_active = false
|
|
res["success"] = true
|
|
return res
|
|
|
|
# 取消交易 (1:1 CExchange::Cancel)
|
|
func cancel_trade() -> void:
|
|
is_active = false
|
|
side_a = null
|
|
side_b = null
|