Files
mtgodot-poc/project/fishing_rod_reel_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

308 lines
11 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.
# 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))