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
+203
View File
@@ -0,0 +1,203 @@
# skill_book_system.gd —— 40250 技能书研读与技能突破进阶系统 1:1 规则引擎
# 严格对照:
# metin2/src/server/game/src/char_item.cpp:2128-2160 (ITEM_SKILLBOOK / 50300)
# metin2/src/server/game/src/char_skill.cpp:416-570 (CHARACTER::LearnSkillByBook)
# metin2/src/server/game/src/char_item.cpp:1657 (Soul Stone 50513 / G->P)
# ClientVS22/UserInterface/Packet.h
extends RefCounted
const SKILL_BOOK_VNUM := 50300
const EXORCISM_SCROLL_VNUM := 71001 # 驱魔卷轴 (AFFECT_SKILL_NO_BOOK_DELAY)
const HERMIT_ADVICE_VNUM := 71094 # 隐士教训 (AFFECT_SKILL_BOOK_BONUS)
const SOUL_STONE_VNUM := 71001 # 魂石 (50513)
const SKILL_BOOK_EXP_COST := 20000 # 40250 char_skill.cpp:430 need_exp = 20000
const SKILL_BOOK_DELAY := 86400 # 24 小时冷却 (LC_IsKorea 86400s)
# 40250 MasterType 枚举
const MASTER_NORMAL := 0 # Lv 1 - 20
const MASTER_M := 1 # M1 - M10 (Lv 21 - 30)
const MASTER_G := 2 # G1 - G10 (Lv 31 - 40)
const MASTER_P := 3 # Perfect Master (Lv 40)
# 错误码
enum Result {
SUCCESS_LEVEL_UP, # 成功并升级(或突破到 G
SUCCESS_READ_ONCE, # 成功研读一本(累积进度)
FAIL_READ, # 研读失败
ERR_NOT_LEARNABLE, # 非本职业/未入流派技能
ERR_NOT_MASTER_TIER, # 尚未达到 M 阶 (Lv < 20)
ERR_ALREADY_G_OR_P, # 已达到 G 阶或更高,无法再读技能书
ERR_INSUFFICIENT_EXP, # 经验不足 20,000
ERR_COOLDOWN, # 24小时冷却中
}
# 魂石善恶值消耗表 (char_skill.cpp / training_grandmaster_skill.quest)
const SOUL_STONE_KARMA_COST := {
1: 1000, 2: 1500, 3: 2000, 4: 2500, 5: 3000,
6: 3500, 7: 4000, 8: 4500, 9: 5000, 10: 5500
}
# 判定技能书升级所需本数:40250 char_skill.cpp:509 need_bookcount = GetSkillLevel(dwSkillVnum) - 20
static func get_need_books(skill_level: int) -> int:
# M1 (21) 需 1 本,M2 (22) 需 2 本 ... M10 (30) 需 10 本
return maxi(1, skill_level - 20)
# 研读条件前置校验
static func check_can_read(
skill_id: int,
skill_level: int,
master_type: int,
player_exp: int,
next_read_time: int,
current_time: int,
has_exorcism: bool
) -> int:
if skill_id <= 0:
return Result.ERR_NOT_LEARNABLE
if master_type < MASTER_M or skill_level < 20:
return Result.ERR_NOT_MASTER_TIER
if master_type > MASTER_M or skill_level >= 30:
return Result.ERR_ALREADY_G_OR_P
if player_exp < SKILL_BOOK_EXP_COST:
return Result.ERR_INSUFFICIENT_EXP
if current_time < next_read_time and not has_exorcism:
return Result.ERR_COOLDOWN
return 0 # OK
# 研读技能书核心判定逻辑(1:1 char_skill.cpp:488-570
static func read_skill_book(
skill_id: int,
skill_level: int,
master_type: int,
read_count: int,
player_exp: int,
next_read_time: int,
current_time: int,
has_exorcism: bool,
has_hermit: bool,
roll_val := -1 # 测试可注入 1..100
) -> Dictionary:
var err := check_can_read(skill_id, skill_level, master_type, player_exp, next_read_time, current_time, has_exorcism)
if err != 0:
return {
"result": err,
"skill_id": skill_id,
"level": skill_level,
"master_type": master_type,
"read_count": read_count,
"exp_consumed": 0,
"next_read_time": next_read_time,
"consumed_exorcism": false,
"consumed_hermit": false,
}
var consumed_exorcism := false
if current_time < next_read_time and has_exorcism:
consumed_exorcism = true
var consumed_hermit := false
var fail_percent := 65 # 40250 char_skill.cpp:525 int percent = 65;
if has_hermit:
fail_percent = 0 # 100% 成功
consumed_hermit = true
var dice := roll_val if roll_val > 0 else (randi() % 100 + 1)
var success := (dice > fail_percent)
var new_level := skill_level
var new_master := master_type
var new_read_count := read_count
var new_next_time := current_time + SKILL_BOOK_DELAY
var res := Result.FAIL_READ
if success:
var need_books := get_need_books(skill_level)
if read_count + 1 >= need_books:
# 升级!
new_level += 1
new_read_count = 0
if new_level >= 30:
new_master = MASTER_G # 突破到 G1
res = Result.SUCCESS_LEVEL_UP
else:
new_read_count += 1
res = Result.SUCCESS_READ_ONCE
return {
"result": res,
"skill_id": skill_id,
"level": new_level,
"master_type": new_master,
"read_count": new_read_count,
"exp_consumed": SKILL_BOOK_EXP_COST,
"next_read_time": new_next_time,
"consumed_exorcism": consumed_exorcism,
"consumed_hermit": consumed_hermit,
"need_books": get_need_books(new_level),
}
# 魂石修炼逻辑(1:1 G1..G10 -> P
static func soul_stone_train(
skill_id: int,
skill_level: int,
master_type: int,
current_karma: int,
roll_val := -1
) -> Dictionary:
if master_type != MASTER_G or skill_level < 30 or skill_level >= 40:
return {"success": false, "err": "not_g_tier"}
var g_step := skill_level - 30 # 1..10
var base_cost: int = SOUL_STONE_KARMA_COST.get(g_step, 1000)
var actual_cost: int = base_cost if current_karma >= 0 else (base_cost * 2)
var dice := roll_val if roll_val > 0 else (randi() % 100 + 1)
# 40250 魂石成功率约 30%
var success := (dice <= 30)
var new_level := skill_level
var new_master := master_type
if success:
new_level += 1
if new_level >= 40:
new_master = MASTER_P
return {
"success": success,
"skill_id": skill_id,
"level": new_level,
"master_type": new_master,
"karma_consumed": actual_cost,
}
# 格式化官方提示文本
static func get_result_message(res_dict: Dictionary) -> String:
match res_dict.get("result", -1):
Result.SUCCESS_LEVEL_UP:
if res_dict.get("master_type") == MASTER_G and res_dict.get("level") == 30:
return "领悟了大宗师的奥秘!技能已成功突破至大宗师(G1)!"
return "完全理解了书中内容,技能成功提升到了新的境界!"
Result.SUCCESS_READ_ONCE:
var remaining := int(res_dict.get("need_books", 1)) - int(res_dict.get("read_count", 0))
return "成功领悟了部分技巧!还需要成功研读 %d 本书以完成突破。" % remaining
Result.FAIL_READ:
return "研读失败,未能领悟书中的奥秘。"
Result.ERR_NOT_LEARNABLE:
return "无法修炼该技能书。"
Result.ERR_NOT_MASTER_TIER:
return "这个技能还未达到大师级别(M),不能通过技能书修炼。"
Result.ERR_ALREADY_G_OR_P:
return "这个技能已达到大宗师或以上级别,无法再通过普通技能书提升。"
Result.ERR_INSUFFICIENT_EXP:
return "经验值不足(需要 20,000 EXP)。"
Result.ERR_COOLDOWN:
var remaining_s := maxi(0, int(res_dict.get("next_read_time", 0)))
return "当前处于研读冷却中,请使用驱魔卷轴消除冷却或稍后再试。"
_:
return ""