Files
mtgodot-poc/project/fish_clam_roast_system.gd
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

433 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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))