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
+432
View File
@@ -0,0 +1,432 @@
# fish_clam_roast_system.gd —— Metin2 40250 官方珍珠蚌开珠与活鱼宰杀/篝火炙烤盛宴系统 1:1
# 100% 对照 40250 服务端 fishing.cpp, char_item.cpp (ITEM_FISH, 27987, USE_ABILITY_UP, USE_INVISIBILITY), item_proto.txt
class_name FishClamRoastSystem
extends RefCounted
signal fish_gutted(result_type: String, item_vnum: int, item_name: String)
signal clam_opened(result_type: String, item_vnum: int, item_name: String)
signal fish_grilled(raw_vnum: int, cooked_vnum: int, count: int)
signal cooked_fish_consumed(fish_vnum: int, effect_type: String, value: int, duration: float)
signal campfire_spawned(pos: Vector2, duration: float)
# 官方核心 Vnum 常量
const VNUM_CLAM := 27987 # 珍珠蚌 (Clam / Istiridye)
const VNUM_STONE_PIECE := 27990 # 碎石 (Stone Piece)
const VNUM_WHITE_PEARL := 27992 # 白珍珠 (White Pearl)
const VNUM_BLUE_PEARL := 27993 # 蓝珍珠 (Blue Pearl)
const VNUM_BLOOD_PEARL := 27994 # 血红珍珠 (Blood Pearl)
const VNUM_FISH_BONE := 27995 # 鱼骨 (Fishbone)
const VNUM_EARTHWORM := 27801 # 蚯蚓/饵料 (Earthworm)
const VNUM_CAMPFIRE_ITEM := 27600 # 篝火道具
const MOB_CAMPFIRE := 12000 # 篝火 NPC/Mob
# 40250 官方鱼类资料表 (alive_vnum, dead_vnum, cooked_vnum, buff/effect)
const FISH_DATA: Dictionary = {
27803: {
"name": "鲈鱼 (Sudak)",
"alive_vnum": 27803,
"dead_vnum": 27833,
"cooked_vnum": 27863,
"effect": "instant_hp",
"value": 180,
"duration": 0.0,
"desc": "立即恢复 180 点生命值"
},
27804: {
"name": "鲱鱼 (Ringa)",
"alive_vnum": 27804,
"dead_vnum": 27834,
"cooked_vnum": 27864,
"effect": "instant_sp",
"value": 150,
"duration": 0.0,
"desc": "立即恢复 150 点魔法值"
},
27806: {
"name": "鲤鱼 (Sazan)",
"alive_vnum": 27806,
"dead_vnum": 27836,
"cooked_vnum": 27866,
"effect": "move_speed",
"value": 20,
"duration": 600.0,
"desc": "移动速度 +20 持续 10 分钟"
},
27807: {
"name": "三文鱼/鲑鱼 (Som)",
"alive_vnum": 27807,
"dead_vnum": 27837,
"cooked_vnum": 27867,
"effect": "instant_sp",
"value": 300,
"duration": 0.0,
"desc": "立即恢复 300 点魔法值"
},
27808: {
"name": "草鱼 (Ot Sazanı)",
"alive_vnum": 27808,
"dead_vnum": 27838,
"cooked_vnum": 27868,
"effect": "attack_speed",
"value": 20,
"duration": 600.0,
"desc": "攻击速度 +20 持续 10 分钟"
},
27810: {
"name": "水针鱼 (Zargana)",
"alive_vnum": 27810,
"dead_vnum": 27840,
"cooked_vnum": 27870,
"effect": "strength",
"value": 10,
"duration": 600.0,
"desc": "力量 (STR) +10 持续 10 分钟"
},
27813: {
"name": "凤尾鱼 (Hamsi)",
"alive_vnum": 27813,
"dead_vnum": 27843,
"cooked_vnum": 27873,
"effect": "dexterity",
"value": 10,
"duration": 600.0,
"desc": "敏捷 (DEX) +10 持续 10 分钟"
},
27814: {
"name": "海鲈鱼 (Levrek)",
"alive_vnum": 27814,
"dead_vnum": 27844,
"cooked_vnum": 27874,
"effect": "cure_poison",
"value": 1,
"duration": 0.0,
"desc": "祛除体内的一切猛毒状态"
},
27817: {
"name": "泥鳅 (Çopra)",
"alive_vnum": 27817,
"dead_vnum": 27847,
"cooked_vnum": 27877,
"effect": "invisibility",
"value": 1,
"duration": 300.0,
"desc": "进入幽冥迷雾隐身状态 5 分钟"
}
}
# 逆向索引
var _dead_to_cooked: Dictionary = {}
var _alive_to_cooked: Dictionary = {}
var _cooked_to_data: Dictionary = {}
# 当前激活的烤鱼增益状态列表
var _active_buffs: Dictionary = {} # effect_type -> {"value": int, "duration": float}
# 当前地图是否存在有效篝火
var is_near_active_campfire: bool = false
var campfire_time_left: float = 0.0
func _init() -> void:
for k in FISH_DATA.keys():
var data = FISH_DATA[k]
_alive_to_cooked[data["alive_vnum"]] = data
_dead_to_cooked[data["dead_vnum"]] = data
_cooked_to_data[data["cooked_vnum"]] = data
# ==========================================
# 1. 活鱼宰杀/使用 (fishing.cpp: UseFish)
# ==========================================
func gut_live_fish(inventory: Array, slot_index: int, forced_roll: int = -1) -> Dictionary:
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有活鱼!"}
var item = inventory[slot_index]
var vnum = int(item.get("vnum", 0))
if not _alive_to_cooked.has(vnum):
return {"ok": false, "reason": "NOT_ALIVE_FISH", "msg": "此物品不是可以剖杀的鲜活鱼类!"}
var fish = _alive_to_cooked[vnum]
# 扣减活鱼数量
_consume_item_one(inventory, slot_index)
# 40250 原版随机概率 1~10000:
# r >= 4001 (60%): 产出死鱼 (dead_vnum)
# r >= 2001 (20%): 产出鱼骨 (FISH_BONE_VNUM 27995)
# r < 2001 (20%): 肚中有物 (蚌壳 27987、饵料 27801 或失手滑入水中)
var r = forced_roll if forced_roll > 0 else randi_range(1, 10000)
if r >= 4001:
_give_or_stack_item(inventory, fish["dead_vnum"], 1)
fish_gutted.emit("dead_fish", fish["dead_vnum"], "" + fish["name"])
return {
"ok": true,
"result": "dead_fish",
"vnum": fish["dead_vnum"],
"name": "" + fish["name"],
"msg": "你熟练地宰杀了活鱼,获得了可用于烤制的死鱼。"
}
elif r >= 2001:
_give_or_stack_item(inventory, VNUM_FISH_BONE, 1)
fish_gutted.emit("fish_bone", VNUM_FISH_BONE, "鱼骨")
return {
"ok": true,
"result": "fish_bone",
"vnum": VNUM_FISH_BONE,
"name": "鱼骨",
"msg": "活鱼体内只剩下一截晶莹剔透的坚硬鱼骨。"
}
else:
# r < 2001
if r <= 1000:
# 珍稀产出:珍珠蚌 (Clam 27987)
_give_or_stack_item(inventory, VNUM_CLAM, 1)
fish_gutted.emit("clam", VNUM_CLAM, "珍珠蚌")
return {
"ok": true,
"result": "clam",
"vnum": VNUM_CLAM,
"name": "珍珠蚌",
"msg": "难以置信!你在鱼肚中发现了一枚紧闭的神秘【珍珠蚌】!"
}
elif r <= 1500:
_give_or_stack_item(inventory, VNUM_EARTHWORM, 1)
fish_gutted.emit("earthworm", VNUM_EARTHWORM, "蚯蚓")
return {
"ok": true,
"result": "earthworm",
"vnum": VNUM_EARTHWORM,
"name": "蚯蚓",
"msg": "鱼肚里还残留着一条未消化的蚯蚓饵料。"
}
else:
# 滑入水中/空
fish_gutted.emit("escape", 0, "溜走")
return {
"ok": true,
"result": "escape",
"vnum": 0,
"name": "",
"msg": "活鱼猛烈扑腾,从你的指缝中滑落回了深水之中!"
}
# ==========================================
# 2. 珍珠蚌开珠 (char_item.cpp:2965 Case 27987)
# ==========================================
func open_clam(
inventory: Array,
slot_index: int,
forced_branch_roll: int = -1,
forced_pearl_roll: int = -1
) -> Dictionary:
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有物品!"}
var item = inventory[slot_index]
var vnum = int(item.get("vnum", 0))
if vnum != VNUM_CLAM:
return {"ok": false, "reason": "NOT_A_CLAM", "msg": "该物品不是珍珠蚌!"}
# 消耗 1 枚珍珠蚌
_consume_item_one(inventory, slot_index)
# 40250 官方第一轮分支 (1~100):
# r <= 50 (50%): 碎石 (27990)
# r > 50 (50%): 判定珍珠概率表 prob_table {80, 90, 97}
var r = forced_branch_roll if forced_branch_roll > 0 else randi_range(1, 100)
if r <= 50:
_give_or_stack_item(inventory, VNUM_STONE_PIECE, 1)
clam_opened.emit("stone", VNUM_STONE_PIECE, "碎石")
return {
"ok": true,
"result": "stone",
"vnum": VNUM_STONE_PIECE,
"name": "碎石",
"msg": "珍珠蚌打开了,但里面只包裹着一块毫无价值的普通碎石。"
}
else:
# 珍珠判定表 (1~100)
# roll <= 80 (80%): 空蚌壳 (什么都没有)
# 81 <= roll <= 90 (10%): 白珍珠 (27992)
# 91 <= roll <= 97 (7%): 蓝珍珠 (27993)
# roll >= 98 (3%): 血红珍珠 (27994)
var p_roll = forced_pearl_roll if forced_pearl_roll > 0 else randi_range(1, 100)
if p_roll <= 80:
clam_opened.emit("empty", 0, "空蚌")
return {
"ok": true,
"result": "empty",
"vnum": 0,
"name": "",
"msg": "蚌壳碎裂开来,里面空空如也,什么也没有留下。"
}
elif p_roll <= 90:
_give_or_stack_item(inventory, VNUM_WHITE_PEARL, 1)
clam_opened.emit("white_pearl", VNUM_WHITE_PEARL, "白珍珠")
return {
"ok": true,
"result": "white_pearl",
"vnum": VNUM_WHITE_PEARL,
"name": "白珍珠",
"msg": "璀璨温润!你从蚌肉中剥出了一颗纯净的【白珍珠】!"
}
elif p_roll <= 97:
_give_or_stack_item(inventory, VNUM_BLUE_PEARL, 1)
clam_opened.emit("blue_pearl", VNUM_BLUE_PEARL, "蓝珍珠")
return {
"ok": true,
"result": "blue_pearl",
"vnum": VNUM_BLUE_PEARL,
"name": "蓝珍珠",
"msg": "幽蓝如海!你收获了一颗弥足珍贵的【蓝珍珠】!"
}
else:
_give_or_stack_item(inventory, VNUM_BLOOD_PEARL, 1)
clam_opened.emit("blood_pearl", VNUM_BLOOD_PEARL, "血红珍珠")
return {
"ok": true,
"result": "blood_pearl",
"vnum": VNUM_BLOOD_PEARL,
"name": "血红珍珠",
"msg": "旷世奇珍!蚌心处赫然闪烁着血煞夺目的极品【血红珍珠】!"
}
# ==========================================
# 3. 篝火炙烤 (char_item.cpp:6880, fishing.cpp: Grill)
# ==========================================
func place_campfire(duration: float = 600.0) -> void:
is_near_active_campfire = true
campfire_time_left = duration
campfire_spawned.emit(Vector2.ZERO, duration)
func grill_fish(inventory: Array, slot_index: int, near_campfire_override: bool = false) -> Dictionary:
var can_grill = is_near_active_campfire or near_campfire_override
if not can_grill:
return {"ok": false, "reason": "NO_CAMPFIRE", "msg": "烤鱼必须在点燃的营火 (篝火) 旁进行!"}
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有鱼类!"}
var item = inventory[slot_index]
var vnum = int(item.get("vnum", 0))
var count = int(item.get("count", 1))
var fish_data = null
if _dead_to_cooked.has(vnum):
fish_data = _dead_to_cooked[vnum]
elif _alive_to_cooked.has(vnum):
fish_data = _alive_to_cooked[vnum]
else:
return {"ok": false, "reason": "NOT_GRILLABLE", "msg": "此物品无法用于篝火炙烤!"}
var cooked_vnum = int(fish_data["cooked_vnum"])
# 40250 item->SetCount(0); AutoGiveItem(grill_vnum, count);
inventory[slot_index] = null
_give_or_stack_item(inventory, cooked_vnum, count)
fish_grilled.emit(vnum, cooked_vnum, count)
return {
"ok": true,
"cooked_vnum": cooked_vnum,
"count": count,
"name": "" + fish_data["name"],
"msg": "营火噼啪作响,鱼肉散发出浓郁扑鼻的焦香!你获得了 %d 条【烤%s】。" % [count, fish_data["name"]]
}
# ==========================================
# 4. 食用熟鱼获得增益 (char_item.cpp: USE_POTION / USE_ABILITY_UP)
# ==========================================
func eat_cooked_fish(inventory: Array, slot_index: int) -> Dictionary:
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有熟鱼!"}
var item = inventory[slot_index]
var vnum = int(item.get("vnum", 0))
if not _cooked_to_data.has(vnum):
return {"ok": false, "reason": "NOT_COOKED_FISH", "msg": "此物品不是烤制完成的熟鱼!"}
var data = _cooked_to_data[vnum]
_consume_item_one(inventory, slot_index)
var effect = str(data["effect"])
var val = int(data["value"])
var dur = float(data["duration"])
if dur > 0.0:
_active_buffs[effect] = {
"value": val,
"time_remaining": dur,
"name": "" + data["name"]
}
cooked_fish_consumed.emit(vnum, effect, val, dur)
return {
"ok": true,
"effect": effect,
"value": val,
"duration": dur,
"desc": data["desc"],
"msg": "你大快朵颐地吃下了香喷喷的烤鱼,%s" % data["desc"]
}
func update(delta: float) -> void:
if campfire_time_left > 0.0:
campfire_time_left -= delta
if campfire_time_left <= 0.0:
campfire_time_left = 0.0
is_near_active_campfire = false
# 更新 BUFF 持续倒计时
var to_remove: Array = []
for eff in _active_buffs.keys():
var b = _active_buffs[eff]
b["time_remaining"] -= delta
if b["time_remaining"] <= 0.0:
to_remove.append(eff)
for eff in to_remove:
_active_buffs.erase(eff)
func get_active_buffs() -> Dictionary:
return _active_buffs
func clear_buffs() -> void:
_active_buffs.clear()
# ==========================================
# 辅助函数: 背包堆叠与消耗
# ==========================================
func _consume_item_one(inventory: Array, slot: int) -> void:
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 {
"active_buffs": _active_buffs.duplicate(true),
"campfire_time_left": campfire_time_left,
"is_near_active_campfire": is_near_active_campfire
}
func deserialize(data: Dictionary) -> void:
_active_buffs = data.get("active_buffs", {}).duplicate(true)
campfire_time_left = float(data.get("campfire_time_left", 0.0))
is_near_active_campfire = bool(data.get("is_near_active_campfire", false))