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
+194
View File
@@ -0,0 +1,194 @@
# endless_spire_dungeon_system.gd —— Metin2 40250 单人单机迷宫地牢【无尽天关】爬塔挑战 1:1
# 对照 40250 服务端 dungeon.cpp, dungeon_info.cpp, questmanager.cpp
class_name EndlessSpireDungeonSystem
extends RefCounted
signal floor_started(floor_num: int, is_boss_floor: bool, mob_count: int)
signal floor_cleared(floor_num: int, points_earned: int, time_used: float)
signal checkpoint_saved(checkpoint_floor: int)
signal spire_trial_completed(highest_floor: int, total_points: int)
signal spire_trial_failed(floor_num: int, reason: String)
const MAX_FLOORS := 50
const FLOOR_TIME_LIMIT := 120.0 # 每层 120 秒限时
const MILESTONE_BOSSES: Dictionary = {
5: {"name": "半兽人首领", "vnum": 691, "hp": 50000},
10: {"name": "蜘蛛女王", "vnum": 2091, "hp": 80000},
15: {"name": "沙漠巨怪", "vnum": 2191, "hp": 120000},
20: {"name": "恶魔死神", "vnum": 1093, "hp": 180000},
25: {"name": "烈焰领主", "vnum": 2206, "hp": 220000},
30: {"name": "九尾妖狐", "vnum": 1901, "hp": 280000},
35: {"name": "黄虎之魂", "vnum": 1304, "hp": 320000},
40: {"name": "冥王阿兹瑞尔", "vnum": 2598, "hp": 400000},
45: {"name": "冰龙贝兰-塞陶", "vnum": 2493, "hp": 500000},
50: {"name": "混沌魔神·阿萨泽尔", "vnum": 8027, "hp": 800000}
}
var is_in_trial: bool = false
var current_floor: int = 1
var highest_checkpoint: int = 1
var total_points: int = 0
var floor_time_remaining: float = 0.0
var active_mobs: Dictionary = {} # mob_id -> { "vnum", "alive" }
var next_mob_id: int = 1
var personal_best_floor: int = 0
# 开启爬塔挑战
func start_trial(from_checkpoint: bool = false) -> Dictionary:
if is_in_trial:
return {"ok": false, "reason": "ALREADY_IN_TRIAL"}
is_in_trial = true
current_floor = highest_checkpoint if from_checkpoint else 1
total_points = 0
next_mob_id = 1
_start_floor(current_floor)
return {
"ok": true,
"starting_floor": current_floor,
"checkpoint": highest_checkpoint,
"msg": "踏入【无尽天关】第 %d 层!" % current_floor
}
# 开启指定层数
func _start_floor(floor_num: int) -> void:
current_floor = floor_num
floor_time_remaining = FLOOR_TIME_LIMIT
active_mobs.clear()
var is_boss = MILESTONE_BOSSES.has(floor_num)
if is_boss:
var b_cfg = MILESTONE_BOSSES[floor_num]
var mid = next_mob_id
next_mob_id += 1
active_mobs[mid] = {
"id": mid,
"vnum": b_cfg["vnum"],
"name": b_cfg["name"],
"is_boss": true,
"alive": true
}
floor_started.emit(floor_num, true, 1)
else:
# 普通层小怪
var mob_count = 4 + (floor_num % 5) * 2
for i in range(mob_count):
var mid = next_mob_id
next_mob_id += 1
active_mobs[mid] = {
"id": mid,
"vnum": 101 + (floor_num % 10),
"name": "天关守卫",
"is_boss": false,
"alive": true
}
floor_started.emit(floor_num, false, mob_count)
# 击杀当前层怪物
func kill_mob(mob_id: int) -> Dictionary:
if not is_in_trial:
return {"ok": false, "reason": "NOT_IN_TRIAL"}
if not active_mobs.has(mob_id) or not active_mobs[mob_id]["alive"]:
return {"ok": false, "reason": "MOB_NOT_FOUND"}
active_mobs[mob_id]["alive"] = false
active_mobs.erase(mob_id)
# 检查本层是否清空
if active_mobs.is_empty():
return _on_floor_cleared()
return {
"ok": true,
"remaining_mobs": active_mobs.size()
}
# 本层通关结算
func _on_floor_cleared() -> Dictionary:
var time_used = FLOOR_TIME_LIMIT - floor_time_remaining
var points_awarded = current_floor * 10
total_points += points_awarded
if current_floor > personal_best_floor:
personal_best_floor = current_floor
floor_cleared.emit(current_floor, points_awarded, time_used)
# 检查点保存判定 (逢 5 层通关)
if current_floor % 5 == 0:
var next_cp = current_floor + 1
if next_cp > highest_checkpoint and next_cp <= MAX_FLOORS:
highest_checkpoint = next_cp
checkpoint_saved.emit(highest_checkpoint)
# 终极 50 层通关判定
if current_floor >= MAX_FLOORS:
is_in_trial = false
spire_trial_completed.emit(MAX_FLOORS, total_points)
return {
"ok": true,
"trial_completed": true,
"highest_floor": MAX_FLOORS,
"total_points": total_points,
"msg": "登峰造极!你已彻底通关【无尽天关】第 50 层巅峰!"
}
# 进阶下一层
var next_f = current_floor + 1
_start_floor(next_f)
return {
"ok": true,
"floor_cleared": current_floor - 1,
"next_floor": next_f,
"points": total_points,
"msg": "%d 层顺利通关!进入第 %d 层!" % [current_floor - 1, next_f]
}
# 帧心跳更新限时
func update(delta: float) -> Dictionary:
if not is_in_trial:
return {"status": "inactive"}
floor_time_remaining -= delta
if floor_time_remaining <= 0.0:
floor_time_remaining = 0.0
is_in_trial = false
spire_trial_failed.emit(current_floor, "TIME_EXPIRED")
return {
"status": "failed",
"reason": "TIME_EXPIRED",
"floor": current_floor,
"msg": "时间耗尽!被传送出天关。"
}
return {
"status": "active",
"floor": current_floor,
"remaining_time": floor_time_remaining,
"remaining_mobs": active_mobs.size()
}
# 中途离开挑战
func exit_trial() -> Dictionary:
if not is_in_trial:
return {"ok": false, "reason": "NOT_IN_TRIAL"}
is_in_trial = false
active_mobs.clear()
return {"ok": true, "msg": "已主动退出天关,检查点进度已保存。"}
# 获取挑战概览
func get_spire_status() -> Dictionary:
return {
"is_in_trial": is_in_trial,
"current_floor": current_floor,
"highest_checkpoint": highest_checkpoint,
"personal_best_floor": personal_best_floor,
"total_points": total_points,
"remaining_time": floor_time_remaining
}