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
@@ -0,0 +1,270 @@
# biologist_chaegirab_research_system.gd —— Metin2 40250 官方生物学者车齿木全套调查研究链与永久属性嘉奖系统 1:1
# 对照 40250 服务端 collect_quest_lv30.quest ~ collect_quest_lv94.quest, char.cpp
class_name BiologistChaegirabResearchSystem
extends RefCounted
signal sample_submitted(item_name: String, success: bool, current: int, target: int)
signal capstone_submitted(stone_name: String, stage_name: String)
signal stage_completed(stage_name: String, rewards: Dictionary)
const NPC_BIOLOGIST := 20084 # 生物学者车齿木 (Biologist Chaegirab)
const VNUM_RESEARCH_ELIXIR := 71035 # 贤者之药 (提高提交成功率至 90%)
# 40250 官方 8 大里程碑阶段全量研究任务链
const RESEARCH_STAGES: Array = [
{
"stage_id": 1,
"title": "【Lv 30 研究】兽人牙齿调查",
"min_level": 30,
"sample_vnum": 30006, # 兽人牙齿
"sample_name": "兽人牙齿",
"target_count": 10,
"soul_stone_vnum": 30220, # 兽人灵魂石
"soul_stone_name": "兽人灵魂石",
"reward_stats": {"move_speed": 10},
"desc": "永久移动速度 +10"
},
{
"stage_id": 2,
"title": "【Lv 40 研究】诅咒之书调查",
"min_level": 40,
"sample_vnum": 30047, # 诅咒之书
"sample_name": "诅咒之书",
"target_count": 15,
"soul_stone_vnum": 30221, # 诅咒灵魂石
"soul_stone_name": "诅咒灵魂石",
"reward_stats": {"cast_speed_pct": 5},
"desc": "永久施法速度 +5%"
},
{
"stage_id": 3,
"title": "【Lv 50 研究】恶魔纪念物调查",
"min_level": 50,
"sample_vnum": 30015, # 恶魔纪念物
"sample_name": "恶魔纪念物",
"target_count": 15,
"soul_stone_vnum": 30222, # 恶魔灵魂石
"soul_stone_name": "恶魔灵魂石",
"reward_stats": {"defense": 60},
"desc": "永久防御力 +60"
},
{
"stage_id": 4,
"title": "【Lv 60 研究】冰之碎片调查",
"min_level": 60,
"sample_vnum": 30050, # 冰之碎片
"sample_name": "冰之碎片",
"target_count": 20,
"soul_stone_vnum": 30223, # 冰霜灵魂石
"soul_stone_name": "冰霜灵魂石",
"reward_stats": {"attack_power": 50},
"desc": "永久物理攻击力 +50"
},
{
"stage_id": 5,
"title": "【Lv 70 研究】幽灵树枝调查",
"min_level": 70,
"sample_vnum": 30165, # 幽灵树枝
"sample_name": "幽灵树枝",
"target_count": 25,
"soul_stone_vnum": 30224, # 森林灵魂石
"soul_stone_name": "森林灵魂石",
"reward_stats": {"move_speed": 11, "damage_reduction_pct": 10},
"desc": "永久移动速度 +11, 伤害抗性 +10%"
},
{
"stage_id": 6,
"title": "【Lv 80 研究】巨灵石碑调查",
"min_level": 80,
"sample_vnum": 30166, # 巨灵石碑
"sample_name": "巨灵石碑",
"target_count": 30,
"soul_stone_vnum": 30225, # 巨灵灵魂石
"soul_stone_name": "巨灵灵魂石",
"reward_stats": {"attack_speed": 6, "attack_pct": 10},
"desc": "永久攻击速度 +6, 物理伤害 +10%"
},
{
"stage_id": 7,
"title": "【Lv 85 研究】红木灵枝调查",
"min_level": 85,
"sample_vnum": 30167, # 红木灵枝
"sample_name": "红木灵枝",
"target_count": 40,
"soul_stone_vnum": 30226, # 灵魂之石
"soul_stone_name": "灵魂之石",
"reward_stats": {"all_resistance_pct": 10},
"desc": "永久全属性抗性 +10%"
},
{
"stage_id": 8,
"title": "【Lv 90 研究】首领领主证书调查",
"min_level": 90,
"sample_vnum": 30168, # 领主证书
"sample_name": "首领领主证书",
"target_count": 50,
"soul_stone_vnum": 30227, # 首领灵魂石
"soul_stone_name": "首领灵魂石",
"reward_stats": {"boss_damage_pct": 10},
"desc": "永久对野外首领伤害 +10%"
}
]
var current_stage_index: int = 0
var current_samples: int = 0
var waiting_for_soul_stone: bool = false
var completed_stages: Array = []
var permanent_bonuses: Dictionary = {}
# 获取当前正在进行的研究任务配置
func get_current_stage() -> Dictionary:
if current_stage_index >= RESEARCH_STAGES.size():
return {} # 已全部完成
return RESEARCH_STAGES[current_stage_index].duplicate(true)
# 交付样本材料
func submit_sample(inventory: Array, slot_index: int, player_level: int, force_success: bool = false, use_elixir: bool = false) -> Dictionary:
var stage = get_current_stage()
if stage.is_empty():
return {"ok": false, "reason": "ALL_RESEARCH_COMPLETED", "msg": "所有生物学者调查研究均已登峰造极!"}
if player_level < int(stage["min_level"]):
return {
"ok": false,
"reason": "PLAYER_LEVEL_TOO_LOW",
"msg": "等级不足!开启该阶段研究需达到 %d 级。" % int(stage["min_level"])
}
if waiting_for_soul_stone:
return {
"ok": false,
"reason": "WAITING_FOR_SOUL_STONE",
"msg": "样本已全部收集齐!请交付【%s】以完成终极认证。" % stage["soul_stone_name"]
}
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "INVALID_SLOT"}
var item: Dictionary = inventory[slot_index]
var item_vnum := int(item.get("vnum", 0))
if item_vnum != int(stage["sample_vnum"]):
return {
"ok": false,
"reason": "WRONG_SAMPLE_ITEM",
"msg": "车齿木摇了摇头:当前阶段需要研究的是【%s】!" % stage["sample_name"]
}
# 消耗 1 件样本
var cnt := int(item.get("count", 1))
if cnt > 1:
item["count"] = cnt - 1
else:
inventory[slot_index] = null
# 判定成功率 (基础 60%,使用贤者之药 90%)
var success_chance := 0.90 if use_elixir else 0.60
var is_success := force_success or (randf() <= success_chance)
if is_success:
current_samples += 1
sample_submitted.emit(stage["sample_name"], true, current_samples, stage["target_count"])
# 检查样本是否集齐
if current_samples >= int(stage["target_count"]):
waiting_for_soul_stone = true
return {
"ok": true,
"success": true,
"current": current_samples,
"target": stage["target_count"],
"need_soul_stone": true,
"soul_stone_name": stage["soul_stone_name"],
"msg": "样本品质极高!%d 件样本已全部收集完毕,请寻找【%s】交付!" % [current_samples, stage["soul_stone_name"]]
}
return {
"ok": true,
"success": true,
"current": current_samples,
"target": stage["target_count"],
"need_soul_stone": false,
"msg": "样本验证成功!研究进度:(%d / %d)。" % [current_samples, stage["target_count"]]
}
else:
sample_submitted.emit(stage["sample_name"], false, current_samples, stage["target_count"])
return {
"ok": true,
"success": false,
"current": current_samples,
"target": stage["target_count"],
"need_soul_stone": false,
"msg": "学者车齿木叹息:这件样本受损严重,无法用于精炼分析。"
}
# 交付关底灵魂石完成该阶段
func submit_soul_stone(inventory: Array, slot_index: int, player_stats: Dictionary) -> Dictionary:
var stage = get_current_stage()
if stage.is_empty():
return {"ok": false, "reason": "ALL_RESEARCH_COMPLETED"}
if not waiting_for_soul_stone:
return {"ok": false, "reason": "SAMPLES_NOT_COMPLETED", "msg": "样本尚未收集齐,无法交付灵魂石!"}
if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
return {"ok": false, "reason": "INVALID_SLOT"}
var item: Dictionary = inventory[slot_index]
if int(item.get("vnum", 0)) != int(stage["soul_stone_vnum"]):
return {
"ok": false,
"reason": "WRONG_SOUL_STONE",
"msg": "需要交付的是专属的【%s】!" % stage["soul_stone_name"]
}
# 消耗灵魂石
var cnt := int(item.get("count", 1))
if cnt > 1:
item["count"] = cnt - 1
else:
inventory[slot_index] = null
# 注入永久属性加成
var rewards: Dictionary = stage.get("reward_stats", {})
for k in rewards.keys():
var val = int(rewards[k])
player_stats[k] = int(player_stats.get(k, 0)) + val
permanent_bonuses[k] = int(permanent_bonuses.get(k, 0)) + val
completed_stages.append(stage["stage_id"])
current_stage_index += 1
current_samples = 0
waiting_for_soul_stone = false
capstone_submitted.emit(stage["soul_stone_name"], stage["title"])
stage_completed.emit(stage["title"], rewards)
return {
"ok": true,
"completed_stage": stage["title"],
"rewards": rewards,
"permanent_bonuses": permanent_bonuses,
"msg": "【%s】圆满达成!永久获得属性加成:%s!" % [stage["title"], stage["desc"]]
}
# 序列化
func serialize() -> Dictionary:
return {
"current_stage_index": current_stage_index,
"current_samples": current_samples,
"waiting_for_soul_stone": waiting_for_soul_stone,
"completed_stages": completed_stages.duplicate(true),
"permanent_bonuses": permanent_bonuses.duplicate(true)
}
# 反序列化
func deserialize(data: Dictionary) -> void:
current_stage_index = int(data.get("current_stage_index", 0))
current_samples = int(data.get("current_samples", 0))
waiting_for_soul_stone = bool(data.get("waiting_for_soul_stone", false))
completed_stages = data.get("completed_stages", []).duplicate(true)
permanent_bonuses = data.get("permanent_bonuses", {}).duplicate(true)