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
+214
View File
@@ -0,0 +1,214 @@
# fishing_cooking_camp_system.gd —— Metin2 40250 野外钓鱼烹饪与生火露营增益系统 1:1
# 对照 40250 服务端 fishing.cpp, char_item.cpp, affect.h
class_name FishingCookingCampSystem
extends RefCounted
signal campfire_lit(pos: Vector3, duration: float)
signal campfire_extinguished()
signal fish_cooked(raw_vnum: int, cooked_vnum: int, cooked_name: String)
signal food_buff_applied(buff_id: String, buff_name: String, duration: float)
signal food_buff_expired(buff_id: String)
const CAMPFIRE_KIT_VNUM := 27600 # 营火工具 (Campfire)
const CAMPFIRE_DURATION := 300.0 # 营火持续 5 分钟
const FOOD_BUFF_DURATION := 1800.0 # 食用增益 30 分钟 (1800 秒)
# 鱼类与烹饪食谱
const RECIPES: Dictionary = {
27802: { # 生鲤鱼 -> 烤鲤鱼
"cooked_vnum": 27832,
"cooked_name": "烤鲤鱼",
"buff_id": "buff_grilled_carp",
"stat_key": "max_hp",
"stat_val": 1000,
"desc": "最大生命上限 +1000,持续 30 分钟"
},
27803: { # 生草鱼 -> 烤草鱼
"cooked_vnum": 27833,
"cooked_name": "烤草鱼",
"buff_id": "buff_grilled_grass_carp",
"stat_key": "move_speed",
"stat_val": 15,
"desc": "移动速度 +15,持续 30 分钟"
},
27815: { # 生红鳟鱼 -> 烤红鳟鱼
"cooked_vnum": 27845,
"cooked_name": "烤红鳟鱼",
"buff_id": "buff_grilled_trout",
"stat_key": "atk",
"stat_val": 50,
"desc": "攻击力 +50,持续 30 分钟"
}
}
# 烤鱼物品映射
const COOKED_FOODS: Dictionary = {
27832: {"buff_id": "buff_grilled_carp", "name": "烤鲤鱼", "stat_key": "max_hp", "stat_val": 1000},
27833: {"buff_id": "buff_grilled_grass_carp", "name": "烤草鱼", "stat_key": "move_speed", "stat_val": 15},
27845: {"buff_id": "buff_grilled_trout", "name": "烤红鳟鱼", "stat_key": "atk", "stat_val": 50}
}
var is_campfire_active: bool = false
var campfire_timer: float = 0.0
var campfire_pos: Vector3 = Vector3.ZERO
# 活跃中的食物 Buff: buff_id -> { "remaining": float, "stat_key": String, "stat_val": int }
var active_buffs: Dictionary = {}
# 点燃野外营火
func light_campfire(pos: Vector3, inventory: Array) -> Dictionary:
var kit_slot = -1
for i in range(inventory.size()):
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == CAMPFIRE_KIT_VNUM:
kit_slot = i
break
if kit_slot == -1:
return {"ok": false, "reason": "NO_CAMPFIRE_KIT", "msg": "背包中没有【营火工具】(27600)"}
# 消耗 1 个营火工具
var item = inventory[kit_slot]
var cnt = int(item.get("count", 1))
if cnt > 1:
item["count"] = cnt - 1
else:
inventory[kit_slot] = null
is_campfire_active = true
campfire_timer = CAMPFIRE_DURATION
campfire_pos = pos
campfire_lit.emit(pos, CAMPFIRE_DURATION)
return {
"ok": true,
"pos": pos,
"duration": CAMPFIRE_DURATION,
"msg": "营火已点燃!散发着温暖火光,可在此烧烤烹饪渔获!"
}
# 在营火旁烹饪烤鱼
func cook_fish(slot: int, inventory: Array) -> Dictionary:
if not is_campfire_active:
return {"ok": false, "reason": "NO_ACTIVE_CAMPFIRE", "msg": "周围没有生起的营火,无法烹饪!"}
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
return {"ok": false, "reason": "INVALID_SLOT"}
var raw_item = inventory[slot]
var raw_vnum = int(raw_item.get("vnum", 0))
if not RECIPES.has(raw_vnum):
return {"ok": false, "reason": "NOT_COOKABLE", "msg": "此物品无法在营火上烹饪!"}
var r = RECIPES[raw_vnum]
# 消耗 1 条生鱼
var cnt = int(raw_item.get("count", 1))
if cnt > 1:
raw_item["count"] = cnt - 1
else:
inventory[slot] = null
# 入包烤鱼
var cooked_vnum: int = r["cooked_vnum"]
var cooked_name: String = r["cooked_name"]
# 寻找可叠加的空格或同类
var placed := false
for i in range(inventory.size()):
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == cooked_vnum:
inventory[i]["count"] = int(inventory[i].get("count", 1)) + 1
placed = true
break
if not placed:
for i in range(inventory.size()):
if inventory[i] == null:
inventory[i] = {
"vnum": cooked_vnum,
"name": cooked_name,
"count": 1
}
placed = true
break
fish_cooked.emit(raw_vnum, cooked_vnum, cooked_name)
return {
"ok": true,
"cooked_name": cooked_name,
"desc": r["desc"],
"msg": "滋滋作响!成功烤制出色香味俱全的【%s】!" % cooked_name
}
# 食用烤鱼获得 30 分钟强力 Buff
func eat_cooked_fish(slot: int, inventory: Array, player_data: Dictionary) -> Dictionary:
if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
return {"ok": false, "reason": "INVALID_SLOT"}
var item = inventory[slot]
var vnum = int(item.get("vnum", 0))
if not COOKED_FOODS.has(vnum):
return {"ok": false, "reason": "NOT_EDIBLE_COOKED_FISH"}
var cfg = COOKED_FOODS[vnum]
var bid: String = cfg["buff_id"]
var stat_k: String = cfg["stat_key"]
var stat_v: int = cfg["stat_val"]
# 消耗 1 条烤鱼
var cnt = int(item.get("count", 1))
if cnt > 1:
item["count"] = cnt - 1
else:
inventory[slot] = null
# 如果已有同类 Buff,先刷新时长并避免重复叠加属性
if active_buffs.has(bid):
active_buffs[bid]["remaining"] = FOOD_BUFF_DURATION
else:
player_data[stat_k] = int(player_data.get(stat_k, 0)) + stat_v
active_buffs[bid] = {
"remaining": FOOD_BUFF_DURATION,
"stat_key": stat_k,
"stat_val": stat_v,
"name": cfg["name"]
}
food_buff_applied.emit(bid, cfg["name"], FOOD_BUFF_DURATION)
return {
"ok": true,
"buff_id": bid,
"buff_name": cfg["name"],
"stat_key": stat_k,
"stat_val": stat_v,
"current_stat": player_data[stat_k],
"msg": "品尝了美味的【%s】!精神焕发,获得了【%s +%d】持续 30 分钟!" % [cfg["name"], stat_k, stat_v]
}
# 帧心跳更新营火与食物 Buff
func update(delta: float, player_data: Dictionary) -> void:
# 营火计时
if is_campfire_active:
campfire_timer -= delta
if campfire_timer <= 0.0:
is_campfire_active = false
campfire_extinguished.emit()
# 食物 Buff 计时与过期反扣
var expired_ids := []
for bid in active_buffs.keys():
var b = active_buffs[bid]
b["remaining"] -= delta
if b["remaining"] <= 0.0:
expired_ids.append(bid)
for bid in expired_ids:
var b = active_buffs[bid]
var stat_k = b["stat_key"]
var stat_v = b["stat_val"]
player_data[stat_k] = max(0, int(player_data.get(stat_k, 0)) - stat_v)
active_buffs.erase(bid)
food_buff_expired.emit(bid)