- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
308 lines
11 KiB
GDScript
308 lines
11 KiB
GDScript
# 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))
|