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
+283
View File
@@ -0,0 +1,283 @@
# horse_level_growth_system.gd —— Metin2 40250 官方马匹等级进化与养殖草料喂食系统 1:1
# 对照 40250 服务端 horse_rider.cpp, char_horse.cpp, constants.h
class_name HorseLevelGrowthSystem
extends RefCounted
signal horse_level_up(new_level: int, tier_name: String)
signal horse_fed(new_health: int, max_health: int)
signal horse_revived(new_health: int)
signal horse_mounted(horse_level: int, stats_applied: Dictionary)
signal horse_dismounted()
signal stamina_exhausted()
# 40250 官方马牌与召唤道具
const VNUM_HORSE_PICTURE := 50051 # 马牌 (初级小马召唤道具)
const VNUM_ARMOR_HORSE_BOOK := 50052 # 战马书 (战斗装甲马召唤道具)
const VNUM_MILITARY_HORSE_BOOK:= 50053 # 军马令 (军阶战马召唤道具)
# 40250 官方草料饲料
const VNUM_HAY := 50054 # 干草 (初级小马食物)
const VNUM_CARROT := 50055 # 胡萝卜 (战斗装甲马食物)
const VNUM_RED_GINSENG := 50056 # 红参 (军阶战马食物)
# 40250 官方复活猴草
const VNUM_HERB_EASY := 50057 # 初级猴草 (复活初级小马)
const VNUM_HERB_NORMAL := 50058 # 中级猴草 (复活战斗装甲马)
const VNUM_HERB_EXPERT := 50059 # 高级猴草 (复活军阶战马)
# 3 大战马阶层定义
const TIER_NONE := 0
const TIER_BEGINNER := 1 # 1~10 级 初级小马 (Race 20101, 人物需求 Lv 25)
const TIER_COMBAT := 2 # 11~20 级 战斗装甲马 (Race 20104, 人物需求 Lv 35)
const TIER_MILITARY := 3 # 21~30 级 军阶战马 (Race 20107, 人物需求 Lv 50)
# 40250 官方经典等级属性表 (选样代表级与通用成长模型)
const HORSE_STAT_TABLE: Dictionary = {
1: {"min_lv": 25, "race": 20101, "max_health": 3, "max_stamina": 4, "st": 26, "dx": 35, "ht": 18, "iq": 9, "atk": 54, "def": 32},
5: {"min_lv": 25, "race": 20101, "max_health": 8, "max_stamina": 6, "st": 30, "dx": 40, "ht": 20, "iq": 10, "atk": 58, "def": 34},
10: {"min_lv": 25, "race": 20101, "max_health": 15, "max_stamina": 10, "st": 35, "dx": 46, "ht": 23, "iq": 12, "atk": 63, "def": 37},
11: {"min_lv": 35, "race": 20104, "max_health": 18, "max_stamina": 30, "st": 40, "dx": 53, "ht": 27, "iq": 13, "atk": 69, "def": 41},
15: {"min_lv": 35, "race": 20104, "max_health": 24, "max_stamina": 55, "st": 44, "dx": 58, "ht": 29, "iq": 15, "atk": 73, "def": 43},
20: {"min_lv": 35, "race": 20104, "max_health": 32, "max_stamina": 100, "st": 48, "dx": 64, "ht": 32, "iq": 16, "atk": 78, "def": 46},
21: {"min_lv": 50, "race": 20107, "max_health": 35, "max_stamina": 120, "st": 53, "dx": 71, "ht": 36, "iq": 18, "atk": 84, "def": 50},
25: {"min_lv": 50, "race": 20107, "max_health": 40, "max_stamina": 140, "st": 60, "dx": 80, "ht": 40, "iq": 20, "atk": 91, "def": 54},
30: {"min_lv": 50, "race": 20107, "max_health": 50, "max_stamina": 200, "st": 67, "dx": 89, "ht": 45, "iq": 22, "atk": 99, "def": 59}
}
var horse_level: int = 0
var current_health: int = 0
var current_stamina: int = 0
var is_mounted: bool = false
var applied_stats: Dictionary = {}
# 获取当前战马阶层
func get_tier() -> int:
if horse_level <= 0:
return TIER_NONE
elif horse_level <= 10:
return TIER_BEGINNER
elif horse_level <= 20:
return TIER_COMBAT
else:
return TIER_MILITARY
# 获取阶层名称
func get_tier_name() -> String:
match get_tier():
TIER_BEGINNER:
return "初级小马"
TIER_COMBAT:
return "战斗装甲马"
TIER_MILITARY:
return "军阶战马"
_:
return "无战马"
# 获取对应等级的基础属性
func get_horse_stat(lv: int) -> Dictionary:
if HORSE_STAT_TABLE.has(lv):
return HORSE_STAT_TABLE[lv].duplicate(true)
# 近似插值计算
var base_lv := 1
for k in HORSE_STAT_TABLE.keys():
if int(k) <= lv and int(k) > base_lv:
base_lv = int(k)
return HORSE_STAT_TABLE[base_lv].duplicate(true)
# 初始化/领养战马
func initialize_horse(level: int = 1) -> void:
horse_level = clampi(level, 1, 30)
var stat = get_horse_stat(horse_level)
current_health = int(stat["max_health"])
current_stamina = int(stat["max_stamina"])
# 马匹等级升级
func upgrade_horse_level(char_level: int) -> Dictionary:
if horse_level >= 30:
return {"ok": false, "reason": "MAX_HORSE_LEVEL", "msg": "战马已达最高等级 30 级!"}
var target_lv := horse_level + 1
var target_stat := get_horse_stat(target_lv)
var req_char_lv := int(target_stat["min_lv"])
if char_level < req_char_lv:
return {
"ok": false,
"reason": "CHAR_LEVEL_TOO_LOW",
"msg": "角色等级不足!晋升至 %d 级战马需要角色达到 %d 级。" % [target_lv, req_char_lv]
}
horse_level = target_lv
var new_stat := get_horse_stat(horse_level)
current_health = int(new_stat["max_health"])
current_stamina = int(new_stat["max_stamina"])
horse_level_up.emit(horse_level, get_tier_name())
return {
"ok": true,
"horse_level": horse_level,
"tier": get_tier_name(),
"msg": "战马成功进阶至 %d 级【%s】!" % [horse_level, get_tier_name()]
}
# 喂食草料恢复健康值 (40250 horse_rider.cpp:FeedHorse)
func feed_horse(inventory: Array, slot_index: int) -> Dictionary:
if horse_level <= 0:
return {"ok": false, "reason": "NO_HORSE"}
if current_health <= 0:
return {"ok": false, "reason": "HORSE_IS_DEAD", "msg": "战马已重伤休克!需要喂食复活猴草唤醒。"}
var stat := get_horse_stat(horse_level)
var max_hp := int(stat["max_health"])
if current_health >= max_hp:
return {"ok": false, "reason": "HEALTH_ALREADY_FULL", "msg": "战马饱食度与健康值已满!"}
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "INVALID_SLOT"}
var food: Dictionary = inventory[slot_index]
var food_vnum := int(food.get("vnum", 0))
var tier := get_tier()
# 校验草料与阶层匹配 (40250 规则: 初级吃干草, 战斗吃胡萝卜, 军马吃红参)
var match_ok := false
if tier == TIER_BEGINNER and food_vnum == VNUM_HAY:
match_ok = true
elif tier == TIER_COMBAT and food_vnum == VNUM_CARROT:
match_ok = true
elif tier == TIER_MILITARY and food_vnum == VNUM_RED_GINSENG:
match_ok = true
if not match_ok:
return {"ok": false, "reason": "FOOD_TIER_MISMATCH", "msg": "该饲料与当前战马阶层不匹配!"}
# 消耗 1 份食物
var cnt := int(food.get("count", 1))
if cnt > 1:
food["count"] = cnt - 1
else:
inventory[slot_index] = null
current_health = mini(max_hp, current_health + 2)
horse_fed.emit(current_health, max_hp)
return {
"ok": true,
"current_health": current_health,
"max_health": max_hp,
"msg": "战马享用了饲料,健康值恢复至 %d/%d" % [current_health, max_hp]
}
# 使用猴草唤醒复活战马
func revive_horse(inventory: Array, slot_index: int) -> Dictionary:
if current_health > 0:
return {"ok": false, "reason": "HORSE_NOT_DEAD", "msg": "战马依然健在,无需使用复活猴草!"}
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "INVALID_SLOT"}
var herb: Dictionary = inventory[slot_index]
var herb_vnum := int(herb.get("vnum", 0))
var tier := get_tier()
var herb_ok := false
if tier == TIER_BEGINNER and herb_vnum == VNUM_HERB_EASY:
herb_ok = true
elif tier == TIER_COMBAT and herb_vnum == VNUM_HERB_NORMAL:
herb_ok = true
elif tier == TIER_MILITARY and herb_vnum == VNUM_HERB_EXPERT:
herb_ok = true
if not herb_ok:
return {"ok": false, "reason": "HERB_TIER_MISMATCH", "msg": "复活猴草阶层与战马不匹配!"}
# 消耗 1 株猴草
var cnt := int(herb.get("count", 1))
if cnt > 1:
herb["count"] = cnt - 1
else:
inventory[slot_index] = null
var stat := get_horse_stat(horse_level)
current_health = maxi(1, int(stat["max_health"]) / 2)
current_stamina = maxi(1, int(stat["max_stamina"]) / 2)
horse_revived.emit(current_health)
return {
"ok": true,
"health": current_health,
"msg": "灵草生效!战马重焕生机复活苏醒。"
}
# 骑乘上马
func mount_horse(player_stats: Dictionary) -> Dictionary:
if horse_level <= 0:
return {"ok": false, "reason": "NO_HORSE", "msg": "尚未获得战马!"}
if current_health <= 0:
return {"ok": false, "reason": "HORSE_IS_DEAD", "msg": "战马已昏迷,需先喂食猴草复活!"}
if is_mounted:
return {"ok": false, "reason": "ALREADY_MOUNTED"}
var stat := get_horse_stat(horse_level)
# 注入战马加成
applied_stats = {
"attack_power": int(stat["atk"]),
"defense": int(stat["def"])
}
for k in applied_stats.keys():
player_stats[k] = int(player_stats.get(k, 0)) + applied_stats[k]
is_mounted = true
horse_mounted.emit(horse_level, applied_stats)
return {"ok": true, "msg": "成功乘骑战马!人马合一属性生效。"}
# 下马
func dismount_horse(player_stats: Dictionary) -> Dictionary:
if not is_mounted:
return {"ok": false, "reason": "NOT_MOUNTED"}
for k in applied_stats.keys():
player_stats[k] = int(player_stats.get(k, 0)) - applied_stats[k]
applied_stats.clear()
is_mounted = false
horse_dismounted.emit()
return {"ok": true, "msg": "已安全下马。"}
# 消耗耐力
func consume_stamina(amount: int, player_stats: Dictionary) -> void:
if not is_mounted:
return
current_stamina = maxi(0, current_stamina - amount)
if current_stamina <= 0:
stamina_exhausted.emit()
dismount_horse(player_stats) # 耐力耗尽自动下马
# 恢复耐力
func recover_stamina(amount: int) -> void:
var stat := get_horse_stat(horse_level)
var max_stamina := int(stat["max_stamina"])
current_stamina = mini(max_stamina, current_stamina + amount)
# 序列化
func serialize() -> Dictionary:
return {
"horse_level": horse_level,
"current_health": current_health,
"current_stamina": current_stamina,
"is_mounted": is_mounted
}
# 反序列化
func deserialize(data: Dictionary) -> void:
horse_level = int(data.get("horse_level", 0))
current_health = int(data.get("current_health", 0))
current_stamina = int(data.get("current_stamina", 0))
is_mounted = bool(data.get("is_mounted", false))