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:
@@ -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))
|
||||
Reference in New Issue
Block a user