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
+309
View File
@@ -0,0 +1,309 @@
# world_boss_system.gd —— Metin2 40250 野外四大世界 Boss 巡回战与专属宝箱 1:1
# 对照 40250 服务端 char_battle.cpp, regen.cpp, mob_drop_item.txt, special_item_group.txt
class_name WorldBossSystem
extends RefCounted
signal boss_spawned(boss_vnum: int, boss_name: String, position: Vector3)
signal boss_hp_changed(current_hp: int, max_hp: int)
signal minions_summoned(boss_vnum: int, minion_vnum: int, count: int)
signal boss_enraged(boss_vnum: int)
signal boss_killed(boss_vnum: int, killer_pos: Vector3, drops: Array)
const BOSS_TABLE: Dictionary = {
1901: {
"name": "九尾妖狐",
"zone": "冰山",
"max_hp": 280000,
"base_atk": 850,
"base_def": 450,
"chest_vnum": 50006,
"chest_name": "九尾妖狐宝箱",
"minion_vnum": 2204, # 冰山雪怪
"minion_count": 4,
"gold_min": 50000,
"gold_max": 100000
},
2191: {
"name": "巨型沙漠龟",
"zone": "永壁沙漠",
"max_hp": 320000,
"base_atk": 920,
"base_def": 520,
"chest_vnum": 50007,
"chest_name": "沙漠龟宝箱",
"minion_vnum": 2108, # 沙漠强盗
"minion_count": 4,
"gold_min": 60000,
"gold_max": 120000
},
1304: {
"name": "黄虎之魂",
"zone": "黄龙神殿",
"max_hp": 350000,
"base_atk": 1050,
"base_def": 480,
"chest_vnum": 50008,
"chest_name": "黄虎宝箱",
"minion_vnum": 1302, # 狂暴神殿狂热信徒
"minion_count": 4,
"gold_min": 70000,
"gold_max": 130000
},
2206: {
"name": "烈焰领主",
"zone": "烈焰原野",
"max_hp": 400000,
"base_atk": 1200,
"base_def": 500,
"chest_vnum": 50009,
"chest_name": "烈焰宝箱",
"minion_vnum": 2202, # 烈焰幽灵
"minion_count": 4,
"gold_min": 80000,
"gold_max": 150000
}
}
# 宝箱开启奖池 (40250 special_item_group.txt 1:1)
const CHEST_LOOT_TABLE: Dictionary = {
50006: [
{"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
{"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
{"vnum": 169, "name": "战神之剑+9", "count": 1, "weight": 15},
{"vnum": 11299, "name": "黑钢甲+9", "count": 1, "weight": 10},
{"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
],
50007: [
{"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
{"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
{"vnum": 3159, "name": "鬼牙刃+9", "count": 1, "weight": 15},
{"vnum": 11499, "name": "黑风甲+9", "count": 1, "weight": 10},
{"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
],
50008: [
{"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
{"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
{"vnum": 1109, "name": "党人斧+9", "count": 1, "weight": 15},
{"vnum": 11699, "name": "黑仙袍+9", "count": 1, "weight": 10},
{"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
],
50009: [
{"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
{"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
{"vnum": 189, "name": "天狼剑+9", "count": 1, "weight": 15},
{"vnum": 11899, "name": "黑龙袍+9", "count": 1, "weight": 10},
{"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
]
}
# 当前活跃的世界 Boss
var active_boss: Dictionary = {}
var is_boss_alive: bool = false
var respawn_timers: Dictionary = {} # mob_vnum -> remaining_cooldown
func spawn_boss(mob_vnum: int, pos: Vector3 = Vector3.ZERO) -> Dictionary:
if not BOSS_TABLE.has(mob_vnum):
return {"ok": false, "reason": "INVALID_BOSS_VNUM", "msg": "未知的世界Boss"}
var cfg: Dictionary = BOSS_TABLE[mob_vnum]
active_boss = {
"vnum": mob_vnum,
"name": cfg["name"],
"zone": cfg["zone"],
"max_hp": cfg["max_hp"],
"current_hp": cfg["max_hp"],
"base_atk": cfg["base_atk"],
"current_atk": cfg["base_atk"],
"base_def": cfg["base_def"],
"current_def": cfg["base_def"],
"chest_vnum": cfg["chest_vnum"],
"chest_name": cfg["chest_name"],
"minion_vnum": cfg["minion_vnum"],
"minion_count": cfg["minion_count"],
"position": pos,
"is_enraged": false,
"phase_75_triggered": false,
"phase_50_triggered": false,
"phase_25_triggered": false,
"summoned_minions": []
}
is_boss_alive = true
boss_spawned.emit(mob_vnum, cfg["name"], pos)
return {
"ok": true,
"boss": active_boss.duplicate(),
"msg": "【世界Boss】%s 已在 %s 现身!" % [cfg["name"], cfg["zone"]]
}
# 承受伤害与阶段机制判定
func apply_damage(raw_dmg: int) -> Dictionary:
if not is_boss_alive or active_boss.is_empty():
return {"ok": false, "reason": "NO_ACTIVE_BOSS", "msg": "当前没有存活的世界Boss"}
var def: int = active_boss["current_def"]
var net_dmg: int = max(1, raw_dmg - def)
active_boss["current_hp"] = max(0, active_boss["current_hp"] - net_dmg)
var cur_hp: int = active_boss["current_hp"]
var max_hp: int = active_boss["max_hp"]
var hp_ratio: float = float(cur_hp) / float(max_hp)
boss_hp_changed.emit(cur_hp, max_hp)
var triggered_events: Array = []
# 1. 阶段小怪召唤机制 (75%, 50%, 25%)
if hp_ratio <= 0.75 and not active_boss["phase_75_triggered"]:
active_boss["phase_75_triggered"] = true
_summon_minions(active_boss["minion_vnum"], active_boss["minion_count"])
triggered_events.append("PHASE_75_MINIONS")
if hp_ratio <= 0.50 and not active_boss["phase_50_triggered"]:
active_boss["phase_50_triggered"] = true
_summon_minions(active_boss["minion_vnum"], active_boss["minion_count"])
triggered_events.append("PHASE_50_MINIONS")
if hp_ratio <= 0.25 and not active_boss["phase_25_triggered"]:
active_boss["phase_25_triggered"] = true
_summon_minions(active_boss["minion_vnum"], active_boss["minion_count"])
triggered_events.append("PHASE_25_MINIONS")
# 2. 残血狂暴机制 (<= 30% HP)
if hp_ratio <= 0.30 and not active_boss["is_enraged"]:
active_boss["is_enraged"] = true
active_boss["current_atk"] = int(active_boss["base_atk"] * 1.5)
boss_enraged.emit(active_boss["vnum"])
triggered_events.append("BOSS_ENRAGED")
# 3. 击杀判定
if cur_hp <= 0:
is_boss_alive = false
var drops: Array = _generate_boss_drops(active_boss["vnum"])
var death_pos: Vector3 = active_boss["position"]
var boss_name: String = active_boss["name"]
var boss_vnum: int = active_boss["vnum"]
boss_killed.emit(boss_vnum, death_pos, drops)
return {
"ok": true,
"killed": true,
"net_dmg": net_dmg,
"remaining_hp": 0,
"drops": drops,
"triggered_events": triggered_events,
"msg": "【世界Boss】%s 已被击杀!掉落专属宝箱!" % boss_name
}
return {
"ok": true,
"killed": false,
"net_dmg": net_dmg,
"remaining_hp": cur_hp,
"hp_ratio": hp_ratio,
"is_enraged": active_boss["is_enraged"],
"current_atk": active_boss["current_atk"],
"triggered_events": triggered_events
}
# 召唤护卫小怪
func _summon_minions(minion_vnum: int, count: int) -> void:
for i in range(count):
var offset := Vector3(randf_range(-3.0, 3.0), 0.0, randf_range(-3.0, 3.0))
active_boss["summoned_minions"].append({
"vnum": minion_vnum,
"pos": active_boss["position"] + offset
})
minions_summoned.emit(active_boss["vnum"], minion_vnum, count)
# 生成 Boss 击杀掉落物
func _generate_boss_drops(mob_vnum: int) -> Array:
var cfg: Dictionary = BOSS_TABLE[mob_vnum]
var drops: Array = []
# 1. 必掉专属宝箱
drops.append({
"vnum": cfg["chest_vnum"],
"name": cfg["chest_name"],
"count": 1
})
# 2. 必掉灵魂之石 (50513)
drops.append({
"vnum": 50513,
"name": "灵魂之石",
"count": 1
})
# 3. 必掉祝福卷轴 (25040)
drops.append({
"vnum": 25040,
"name": "祝福卷轴",
"count": 1
})
# 4. 大额金币掉落 (散落地上)
var gold_amount: int = randi_range(cfg["gold_min"], cfg["gold_max"])
drops.append({
"vnum": 1, # 金币
"name": "金币",
"count": gold_amount
})
return drops
# 开启世界 Boss 宝箱
func open_boss_chest(chest_slot: int, inventory: Array) -> Dictionary:
if chest_slot < 0 or chest_slot >= inventory.size():
return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的背包格子!"}
var item = inventory[chest_slot]
if item == null:
return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有物品!"}
var chest_vnum: int = int(item.get("vnum", 0))
if not CHEST_LOOT_TABLE.has(chest_vnum):
return {"ok": false, "reason": "NOT_A_BOSS_CHEST", "msg": "这不是世界 Boss 宝箱!"}
var pool: Array = CHEST_LOOT_TABLE[chest_vnum]
# 权重抽取奖励
var total_weight: int = 0
for entry in pool:
total_weight += int(entry.get("weight", 1))
var roll := randi_range(1, total_weight)
var accumulated := 0
var chosen_reward: Dictionary = {}
for entry in pool:
accumulated += int(entry.get("weight", 1))
if roll <= accumulated:
chosen_reward = entry.duplicate()
break
if chosen_reward.is_empty():
chosen_reward = pool[0].duplicate()
# 扣除宝箱
inventory[chest_slot] = null
# 放入抽中的奖励
var stored_slot := -1
for i in range(inventory.size()):
if inventory[i] == null:
inventory[i] = {
"vnum": chosen_reward["vnum"],
"count": chosen_reward.get("count", 1),
"name": chosen_reward["name"]
}
stored_slot = i
break
return {
"ok": true,
"reward": chosen_reward,
"stored_slot": stored_slot,
"msg": "打开宝箱获得了【%s】!" % chosen_reward["name"]
}