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
+233
View File
@@ -0,0 +1,233 @@
# class_specialization_system.gd —— Metin2 40250 角色 30 级双流派转职与无损洗髓系统 1:1
# 对照 40250 服务端 quest/skill_group.quest, char_skill.cpp, cmd_general.cpp
class_name ClassSpecializationSystem
extends RefCounted
signal specialization_chosen(job: int, spec_id: int, spec_name: String)
signal skill_point_allocated(skill_vnum: int, new_level: int, remaining_points: int)
signal specialization_reset(refunded_points: int)
const VNUM_RESET_SCROLL := 71100 # 洗髓卷轴 / 重新分配技能书 (Scroll of Redistribution)
const REQUIRED_LEVEL := 30 # 转职等级门槛
const SPEC_TREE: Dictionary = {
0: { # 战士
1: {
"name": "战狂流派 (Arahan)",
"desc": "极度偏向狂暴输出与高速连斩,兼具霸体与破坏力",
"skills": {
1: "三连斩",
2: "旋风斩",
3: "狂战士",
4: "剑气",
5: "冲锋"
}
},
2: {
"name": "圣战流派 (Partisan)",
"desc": "坚若磐石的钢铁之躯,以强身术与精神打击粉碎强敌",
"skills": {
16: "强身术",
17: "精神打击",
18: "宝剑斩",
19: "震地波",
20: "剑气斩"
}
}
},
1: { # 刺客
1: {
"name": "近战刺客流派 (Dagger)",
"desc": "隐身于阴影之中,擅长淬毒与致命近身背刺",
"skills": {
31: "伏击",
32: "绝杀",
33: "匕首旋风",
34: "隐身术",
35: "毒刃"
}
},
2: {
"name": "远程弓箭流派 (Archer)",
"desc": "千里之外取敌首级,漫天箭雨与火炎穿透射击",
"skills": {
46: "连射",
47: "箭雨",
48: "火炎箭",
49: "疾风步",
50: "毒箭"
}
}
},
2: { # 修罗
1: {
"name": "魔剑修罗流派 (Weapon)",
"desc": "附魔之刃附体,吸血噬魂与破魔剑气并存",
"skills": {
61: "附魔之刃",
62: "附魔之甲",
63: "惧意",
64: "龙卷风",
65: "手指戳击"
}
},
2: {
"name": "黑魔法修罗流派 (Black Magic)",
"desc": "掌控幽冥暗焰,幽暗之盾抵消伤害,黑暗之球远程轰杀",
"skills": {
76: "幽冥一击",
77: "烈焰一击",
78: "黑暗之球",
79: "幽暗之盾",
80: "鬼影步"
}
}
},
3: { # 萨满
1: {
"name": "巨龙神圣辅助流派 (Dragon)",
"desc": "掌控巨龙图腾之力,为团队提供巨龙之赐与龙之协助光环",
"skills": {
91: "飞龙射击",
92: "巨龙射击",
93: "巨龙咆哮",
94: "巨龙之赐",
95: "龙之协助"
}
},
2: {
"name": "雷电神圣治疗流派 (Lightning)",
"desc": "引动九天雷电轰杀群敌,施展神圣治愈术与疾风迅捷",
"skills": {
106: "雷电箭",
107: "召唤雷电",
108: "雷电之爪",
109: "治愈术",
110: "迅捷步"
}
}
}
}
# 获取职业可选流派
func get_available_specializations(job: int) -> Dictionary:
return SPEC_TREE.get(job, {})
# 进行 30 级转职选定流派
func choose_specialization(spec_id: int, player_data: Dictionary) -> Dictionary:
var job: int = int(player_data.get("job", 0))
var lvl: int = int(player_data.get("level", 1))
if lvl < REQUIRED_LEVEL:
return {"ok": false, "reason": "LEVEL_TOO_LOW", "msg": "转职需要角色等级达到 30 级!当前等级: %d" % lvl}
var cur_group: int = int(player_data.get("skill_group", 0))
if cur_group != 0:
return {"ok": false, "reason": "ALREADY_SPECIALIZED", "msg": "你已选定了流派!若需变更,请先使用【洗髓卷轴】。"}
if not SPEC_TREE.has(job) or not SPEC_TREE[job].has(spec_id):
return {"ok": false, "reason": "INVALID_SPEC_ID", "msg": "无效的流派代码!"}
var spec = SPEC_TREE[job][spec_id]
player_data["skill_group"] = spec_id
# 初始化该流派专属 5 大技能
if not player_data.has("skills"):
player_data["skills"] = {}
for svnum in spec["skills"].keys():
player_data["skills"][svnum] = {
"name": spec["skills"][svnum],
"level": 0,
"master_type": 0
}
specialization_chosen.emit(job, spec_id, spec["name"])
return {
"ok": true,
"job": job,
"spec_id": spec_id,
"spec_name": spec["name"],
"skills_count": spec["skills"].size(),
"msg": "恭喜!成功完成 30 级转职,踏入【%s】流派之道!" % spec["name"]
}
# 学习与加点技能
func allocate_skill_point(skill_vnum: int, player_data: Dictionary) -> Dictionary:
var cur_group: int = int(player_data.get("skill_group", 0))
if cur_group == 0:
return {"ok": false, "reason": "NOT_SPECIALIZED", "msg": "尚未进行 30 级转职,无法修习流派绝学!"}
var pts: int = int(player_data.get("skill_points", 0))
if pts <= 0:
return {"ok": false, "reason": "NO_SKILL_POINTS", "msg": "技能点数不足!"}
var skills: Dictionary = player_data.get("skills", {})
if not skills.has(skill_vnum):
return {"ok": false, "reason": "SKILL_NOT_AVAILABLE", "msg": "此技能不属于当前专精流派!"}
var sk = skills[skill_vnum]
var cur_lvl: int = int(sk.get("level", 0))
if cur_lvl >= 20:
return {"ok": false, "reason": "MAX_NORMAL_LEVEL", "msg": "该技能基础等级已满 (20),进阶需使用技能秘籍!"}
sk["level"] = cur_lvl + 1
player_data["skill_points"] = pts - 1
skill_point_allocated.emit(skill_vnum, sk["level"], player_data["skill_points"])
return {
"ok": true,
"skill_vnum": skill_vnum,
"new_level": sk["level"],
"remaining_points": player_data["skill_points"],
"msg": "%s】提升至等级 %d" % [sk.get("name", "技能"), sk["level"]]
}
# 使用洗髓卷轴 (71100) 无损重置流派与返还技能点
func reset_specialization(inventory: Array, player_data: Dictionary, free_reset: bool = false) -> Dictionary:
var cur_group: int = int(player_data.get("skill_group", 0))
if cur_group == 0:
return {"ok": false, "reason": "NOT_SPECIALIZED", "msg": "当前尚未选定任何专精流派,无需洗髓!"}
# 卷轴消耗检查
var scroll_slot = -1
if not free_reset:
for i in range(inventory.size()):
if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_RESET_SCROLL:
scroll_slot = i
break
if scroll_slot == -1:
return {"ok": false, "reason": "NEED_RESET_SCROLL", "msg": "需要【洗髓卷轴】(71100) 才能重置专精流派!"}
# 统计已投入的所有技能点数
var skills: Dictionary = player_data.get("skills", {})
var total_invested: int = 0
for svnum in skills.keys():
var sk = skills[svnum]
total_invested += int(sk.get("level", 0))
# 消耗卷轴
if scroll_slot != -1:
var sc_item = inventory[scroll_slot]
var sc_cnt = int(sc_item.get("count", 1))
if sc_cnt > 1:
sc_item["count"] = sc_cnt - 1
else:
inventory[scroll_slot] = null
# 全额返还技能点
var cur_pts: int = int(player_data.get("skill_points", 0))
player_data["skill_points"] = cur_pts + total_invested
player_data["skill_group"] = 0
player_data["skills"] = {}
specialization_reset.emit(total_invested)
return {
"ok": true,
"refunded_points": total_invested,
"total_skill_points": player_data["skill_points"],
"msg": "洗髓成功!经脉重铸,%d 点技能点数全额返还,可重新选定双分支流派!" % total_invested
}