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
+307
View File
@@ -0,0 +1,307 @@
# fishing_rod_reel_system.gd —— Metin2 40250 官方钓鱼抛竿拉杆与浮漂反应小游戏机制 1:1
# 100% 对照 40250 服务端 fishing.cpp (CreateFishingEvent, Take, FishingPractice, RealRefineRod), fishing.h
class_name FishingRodReelSystem
extends RefCounted
signal line_cast(rod_level: int, bait_name: String)
signal fish_bite(reaction_time_window: float)
signal fish_caught(fish_vnum: int, fish_name: String, fish_length_cm: float, practice_points: int)
signal fish_escaped(reason: String, practice_points: int)
signal rod_refined(new_vnum: int, success: bool, new_level: int)
signal fishing_error(reason: String)
# 40250 官方钓竿 Vnum (27400 基础钓鱼竿+0 ~ 27500 +9)
const BASE_ROD_VNUM := 27400 # 钓鱼竿+0
const MAX_ROD_LEVEL := 9
# 40250 官方饵料 Vnum
const VNUM_PASTE := 27800 # 鱼饵面团 (Paste)
const VNUM_EARTHWORM := 27801 # 蚯蚓 (Earthworm)
# 40250 渔夫 NPC
const NPC_FISHERMAN := 9009
# 钓竿进阶最大熟练度点数表与成功率 (对照 item_proto Value(2) 与 Value(3))
const ROD_LEVEL_DATA: Dictionary = {
0: {"max_points": 10, "refine_chance": 100, "base_catch_rate": 0.50},
1: {"max_points": 20, "refine_chance": 90, "base_catch_rate": 0.55},
2: {"max_points": 40, "refine_chance": 80, "base_catch_rate": 0.60},
3: {"max_points": 80, "refine_chance": 70, "base_catch_rate": 0.65},
4: {"max_points": 120, "refine_chance": 60, "base_catch_rate": 0.70},
5: {"max_points": 160, "refine_chance": 50, "base_catch_rate": 0.75},
6: {"max_points": 200, "refine_chance": 40, "base_catch_rate": 0.80},
7: {"max_points": 250, "refine_chance": 30, "base_catch_rate": 0.85},
8: {"max_points": 300, "refine_chance": 20, "base_catch_rate": 0.90},
9: {"max_points": 350, "refine_chance": 10, "base_catch_rate": 0.95}
}
# 常见垂钓渔获池 (对照 fishing.cpp)
const CATCH_POOL: Array[Dictionary] = [
{"vnum": 27803, "name": "鲈鱼 (Sudak)", "min_len": 15.0, "max_len": 35.0, "weight": 35},
{"vnum": 27806, "name": "鲤鱼 (Sazan)", "min_len": 25.0, "max_len": 65.0, "weight": 25},
{"vnum": 27808, "name": "草鱼 (Ot Sazanı)", "min_len": 20.0, "max_len": 55.0, "weight": 20},
{"vnum": 27810, "name": "水针鱼 (Zargana)", "min_len": 30.0, "max_len": 70.0, "weight": 10},
{"vnum": 27817, "name": "泥鳅 (Çopra)", "min_len": 8.0, "max_len": 18.0, "weight": 5},
{"vnum": 70201, "name": "漂白水 (Bleach)", "min_len": 0.0, "max_len": 0.0, "weight": 3},
{"vnum": 27987, "name": "珍稀珍珠蚌 (Clam)", "min_len": 5.0, "max_len": 12.0, "weight": 2}
]
# 当前垂钓状态
var is_fishing: bool = false
var has_bait: bool = false
var current_bait_vnum: int = 0
var rod_level: int = 0
var rod_practice_points: int = 0
var bite_timer: float = 0.0
var is_bite_active: bool = false
var bite_reaction_time_left: float = 0.0
const REACTION_WINDOW_SECONDS := 3.0 # 3 秒拉杆反应时间窗口
# ==========================================
# 1. 挂饵与抛竿 (fishing.cpp: CreateFishingEvent)
# ==========================================
# 穿戴/设定当前鱼竿
func equip_rod(level: int, current_points: int = 0) -> void:
rod_level = clamp(level, 0, MAX_ROD_LEVEL)
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
rod_practice_points = clamp(current_points, 0, max_p)
# 挂上鱼饵 (面团 27800 或 蚯蚓 27801)
func attach_bait(inventory: Array, bait_slot: int) -> Dictionary:
if is_fishing:
fishing_error.emit("ALREADY_FISHING")
return {"ok": false, "reason": "ALREADY_FISHING", "msg": "正在垂钓中,无法更换鱼饵!"}
if bait_slot < 0 or bait_slot >= inventory.size() or inventory[bait_slot] == null:
fishing_error.emit("INVALID_SLOT")
return {"ok": false, "reason": "INVALID_SLOT", "msg": "未找到指定的鱼饵!"}
var item = inventory[bait_slot]
var vnum = int(item.get("vnum", 0))
if vnum != VNUM_PASTE and vnum != VNUM_EARTHWORM:
fishing_error.emit("NOT_BAIT")
return {"ok": false, "reason": "NOT_BAIT", "msg": "该物品无法作为鱼饵挂在鱼钩上!"}
_consume_item_one(inventory, bait_slot)
has_bait = true
current_bait_vnum = vnum
var bait_name = "鱼饵面团" if vnum == VNUM_PASTE else "蚯蚓"
return {"ok": true, "bait_vnum": vnum, "bait_name": bait_name, "msg": "将【%s】小心地挂在了鱼钩上。" % bait_name}
# 抛竿垂钓
func cast_line(forced_wait_time: float = -1.0) -> Dictionary:
if not has_bait:
fishing_error.emit("NO_BAIT")
return {"ok": false, "reason": "NO_BAIT", "msg": "鱼钩上空无一物!请先挂上蚯蚓或面团饵料。"}
if is_fishing:
fishing_error.emit("ALREADY_FISHING")
return {"ok": false, "reason": "ALREADY_FISHING", "msg": "钓线已经抛入水中,静候鱼儿上钩。"}
is_fishing = true
is_bite_active = false
has_bait = false # 抛竿时消耗鱼钩上的鱼饵
# 40250 等待上钩时间: 2~7 秒
bite_timer = forced_wait_time if forced_wait_time > 0.0 else randf_range(2.0, 7.0)
bite_reaction_time_left = 0.0
var bait_name = "鱼饵面团" if current_bait_vnum == VNUM_PASTE else "蚯蚓"
line_cast.emit(rod_level, bait_name)
return {"ok": true, "msg": "钓竿轻扬,鱼线带着微澜划破水面,沉入了平静的湖心……"}
# ==========================================
# 2. 反应窗口拉杆提竿 (fishing.cpp: Take)
# ==========================================
func pull_rod(inventory: Array, forced_catch_idx: int = -1, forced_success: bool = false) -> Dictionary:
if not is_fishing:
fishing_error.emit("NOT_FISHING")
return {"ok": false, "reason": "NOT_FISHING", "msg": "你并未处于垂钓状态!"}
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
# 提前起竿 (鱼儿未咬钩)
if not is_bite_active:
is_fishing = false
bite_timer = 0.0
# 哪怕失败亦获得 1 点熟练度练习 (FishingPractice)
if rod_practice_points < max_p:
rod_practice_points += 1
fish_escaped.emit("PULLED_TOO_EARLY", rod_practice_points)
return {
"ok": false,
"reason": "PULLED_TOO_EARLY",
"practice_points": rod_practice_points,
"msg": "起竿太早了!鱼钩划破空荡荡的水面,鱼饵不翼而飞。"
}
# 鱼已咬钩:计算捕获概率
is_fishing = false
is_bite_active = false
# 熟练度 +1
if rod_practice_points < max_p:
rod_practice_points += 1
var base_rate = float(ROD_LEVEL_DATA[rod_level]["base_catch_rate"])
if current_bait_vnum == VNUM_EARTHWORM:
base_rate += 0.05 # 蚯蚓比面团成功率多 5%
var roll = randf()
var success = (roll <= base_rate) or forced_success
if success:
# 捕获成功:从鱼池选取
var picked_fish: Dictionary = {}
if forced_catch_idx >= 0 and forced_catch_idx < CATCH_POOL.size():
picked_fish = CATCH_POOL[forced_catch_idx]
else:
var total_w := 0
for f in CATCH_POOL:
total_w += int(f["weight"])
var r = randi_range(1, total_w)
var cur := 0
for f in CATCH_POOL:
cur += int(f["weight"])
if r <= cur:
picked_fish = f
break
var fish_vnum = int(picked_fish["vnum"])
var fish_name = str(picked_fish["name"])
var length_cm = randf_range(float(picked_fish["min_len"]), float(picked_fish["max_len"]))
_give_or_stack_item(inventory, fish_vnum, 1)
fish_caught.emit(fish_vnum, fish_name, length_cm, rod_practice_points)
return {
"ok": true,
"fish_vnum": fish_vnum,
"fish_name": fish_name,
"length_cm": length_cm,
"practice_points": rod_practice_points,
"msg": "水花四溅!你收紧鱼线,成功钓上了一条 %.1f cm 的【%s】!" % [length_cm, fish_name]
}
else:
# 挣脱脱钩
fish_escaped.emit("FISH_GOT_AWAY", rod_practice_points)
return {
"ok": false,
"reason": "FISH_GOT_AWAY",
"practice_points": rod_practice_points,
"msg": "大鱼猛烈摆尾,挣脱鱼钩游回了深水,只留下一串水泡……"
}
# ==========================================
# 3. 渔夫 NPC 鱼竿升星锻造 (fishing.cpp: RealRefineRod)
# ==========================================
func can_refine_rod() -> bool:
if rod_level >= MAX_ROD_LEVEL:
return false
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
return rod_practice_points >= max_p
func refine_rod_at_fisherman(forced_roll: int = -1) -> Dictionary:
if not can_refine_rod():
fishing_error.emit("PRACTICE_NOT_MAXED")
return {
"ok": false,
"reason": "PRACTICE_NOT_MAXED",
"msg": "鱼竿熟练度尚未练满!需要 %d/%d 熟练度点数方可前往渔夫处升级。" % [
rod_practice_points, ROD_LEVEL_DATA[rod_level]["max_points"]
]
}
var chance = int(ROD_LEVEL_DATA[rod_level]["refine_chance"])
var r = forced_roll if forced_roll > 0 else randi_range(1, 100)
if r <= chance:
# 锻造升级成功
rod_level += 1
rod_practice_points = 0
var new_vnum = BASE_ROD_VNUM + rod_level
rod_refined.emit(new_vnum, true, rod_level)
return {
"ok": true,
"success": true,
"new_level": rod_level,
"new_vnum": new_vnum,
"msg": "渔夫巧手翻飞,金光闪烁!你的钓鱼竿成功进阶为【钓鱼竿+%d】!" % rod_level
}
else:
# 锻造失败:降 1 级 (40250 官方机制)
rod_level = max(0, rod_level - 1)
rod_practice_points = 0
var new_vnum = BASE_ROD_VNUM + rod_level
rod_refined.emit(new_vnum, false, rod_level)
return {
"ok": true,
"success": false,
"new_level": rod_level,
"new_vnum": new_vnum,
"msg": "精炼失败了!鱼竿受损,品质降为了【钓鱼竿+%d】。" % rod_level
}
# ==========================================
# 4. 心跳推进与超时判定
# ==========================================
func update(delta: float) -> void:
if not is_fishing:
return
if not is_bite_active:
# 等待鱼咬钩
bite_timer -= delta
if bite_timer <= 0.0:
is_bite_active = true
bite_reaction_time_left = REACTION_WINDOW_SECONDS
fish_bite.emit(REACTION_WINDOW_SECONDS)
else:
# 鱼咬钩等待提竿窗口
bite_reaction_time_left -= delta
if bite_reaction_time_left <= 0.0:
# 超时逃跑
is_fishing = false
is_bite_active = false
var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
if rod_practice_points < max_p:
rod_practice_points += 1
fish_escaped.emit("REACTION_TIMEOUT", rod_practice_points)
func _consume_item_one(inventory: Array, slot: int) -> void:
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
return
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 {
"rod_level": rod_level,
"rod_practice_points": rod_practice_points,
"has_bait": has_bait,
"current_bait_vnum": current_bait_vnum
}
func deserialize(data: Dictionary) -> void:
rod_level = int(data.get("rod_level", 0))
rod_practice_points = int(data.get("rod_practice_points", 0))
has_bait = bool(data.get("has_bait", false))
current_bait_vnum = int(data.get("current_bait_vnum", 0))