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
+207
View File
@@ -0,0 +1,207 @@
# gm_toolkit_system.gd —— Metin2 40250 单人单机内置 GM 控制台与命令解析器 1:1
# 对照 40250 服务端 cmd_gm.cpp, cmd.cpp
class_name GmToolkitSystem
extends RefCounted
signal command_executed(cmd: String, success: bool, result_msg: String)
signal player_warped(target_pos: Vector3, map_name: String)
signal mob_spawned(vnum: int, count: int, pos: Vector3)
const MAP_PRESETS: Dictionary = {
"village": Vector3(0.0, 0.0, 0.0),
"sohan": Vector3(400.0, 0.0, 600.0), # 冰山 Mount Sohan
"desert": Vector3(200.0, 0.0, 300.0), # 永壁沙漠 Yongbi Desert
"temple": Vector3(100.0, 0.0, 500.0), # 黄龙神殿 Hwang Temple
"flame": Vector3(600.0, 0.0, 200.0), # 烈焰原野 Doyyumhwan
"catacomb": Vector3(500.0, 0.0, 500.0) # 地下墓穴 Devil's Catacomb
}
# 解析并执行 GM 斜杠指令
func execute_command(cmd_string: String, ctx: Dictionary) -> Dictionary:
var trimmed := cmd_string.strip_edges()
if not trimmed.begins_with("/"):
return {"ok": false, "reason": "NOT_A_COMMAND", "msg": "指令必须以 '/' 开头!"}
var parts := trimmed.substr(1).split(" ", false)
if parts.is_empty():
return {"ok": false, "reason": "EMPTY_COMMAND"}
var action := parts[0].to_lower()
var res: Dictionary = {}
match action:
"item":
res = _handle_item(parts, ctx)
"level":
res = _handle_level(parts, ctx)
"gold":
res = _handle_gold(parts, ctx)
"set_skill", "skill":
res = _handle_skill(parts, ctx)
"warp", "map":
res = _handle_warp(parts, ctx)
"spawn_mob", "mob", "m":
res = _handle_mob(parts, ctx)
"help":
res = _handle_help()
_:
res = {"ok": false, "reason": "UNKNOWN_COMMAND", "msg": "未知的 GM 指令【%s】!输入 /help 查看支持的指令列表。" % action}
command_executed.emit(action, res.get("ok", false), res.get("msg", ""))
return res
# /item <vnum> [count]
func _handle_item(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
if parts.size() < 2 or not parts[1].is_valid_int():
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /item <物品代码vnum> [数量count]"}
var vnum: int = int(parts[1])
var count: int = 1
if parts.size() >= 3 and parts[2].is_valid_int():
count = maxi(1, int(parts[2]))
var inventory: Array = ctx.get("inventory", [])
var empty_slot := -1
for i in range(inventory.size()):
if inventory[i] == null:
empty_slot = i
break
if empty_slot == -1:
return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法生成物品!"}
inventory[empty_slot] = {
"vnum": vnum,
"name": "道具_%d" % vnum,
"count": count
}
return {
"ok": true,
"vnum": vnum,
"count": count,
"slot": empty_slot,
"msg": "【GM】已成功生成物品 [vnum: %d, count: %d] 至背包格子 %d" % [vnum, count, empty_slot]
}
# /level <1-105>
func _handle_level(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
if parts.size() < 2 or not parts[1].is_valid_int():
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /level <1-105>"}
var target_lv: int = clampi(int(parts[1]), 1, 105)
var player_data: Dictionary = ctx.get("player_data", {})
player_data["level"] = target_lv
return {
"ok": true,
"level": target_lv,
"msg": "【GM】角色等级已修改为 Lv %d" % target_lv
}
# /gold <amount>
func _handle_gold(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
if parts.size() < 2 or not parts[1].is_valid_int():
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /gold <金币数额>"}
var amount: int = int(parts[1])
var player_data: Dictionary = ctx.get("player_data", {})
var current: int = int(player_data.get("gold", 0))
var new_gold: int = maxi(0, current + amount)
player_data["gold"] = new_gold
return {
"ok": true,
"added": amount,
"total_gold": new_gold,
"msg": "【GM】金币已注入!当前金币:%d Yang。" % new_gold
}
# /set_skill <skill_id> <1-40>
func _handle_skill(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
if parts.size() < 3 or not parts[1].is_valid_int() or not parts[2].is_valid_int():
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /set_skill <技能ID> <等级1-40> (40=P级)"}
var skill_id: int = int(parts[1])
var skill_lv: int = clampi(int(parts[2]), 1, 40)
var skills: Dictionary = ctx.get("skills", {})
skills[skill_id] = skill_lv
var grade_str := "普通"
if skill_lv >= 40:
grade_str = "P级 (Perfect Master)"
elif skill_lv >= 30:
grade_str = "G级 (Grand Master)"
elif skill_lv >= 20:
grade_str = "M级 (Master)"
return {
"ok": true,
"skill_id": skill_id,
"skill_level": skill_lv,
"grade": grade_str,
"msg": "【GM】技能 [%d] 等级已调整为 %d (%s)" % [skill_id, skill_lv, grade_str]
}
# /warp <x> <y> 或 /map <preset_name>
func _handle_warp(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
var target_pos := Vector3.ZERO
var map_name := "custom"
if parts.size() >= 3 and parts[1].is_valid_float() and parts[2].is_valid_float():
target_pos = Vector3(float(parts[1]), 0.0, float(parts[2]))
elif parts.size() >= 2:
var preset_key: String = parts[1].to_lower()
if MAP_PRESETS.has(preset_key):
target_pos = MAP_PRESETS[preset_key]
map_name = preset_key
else:
return {"ok": false, "reason": "UNKNOWN_MAP", "msg": "未知的预设地图!可用地图: %s" % str(MAP_PRESETS.keys())}
else:
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /warp <x> <y> 或 /map <地图名>"}
player_warped.emit(target_pos, map_name)
return {
"ok": true,
"position": target_pos,
"map_name": map_name,
"msg": "【GM】已瞬移传送至目标坐标 (%.1f, %.1f) [%s]" % [target_pos.x, target_pos.z, map_name]
}
# /spawn_mob <vnum> [count]
func _handle_mob(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
if parts.size() < 2 or not parts[1].is_valid_int():
return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /spawn_mob <怪代码vnum> [数量count]"}
var mob_vnum: int = int(parts[1])
var count: int = 1
if parts.size() >= 3 and parts[2].is_valid_int():
count = maxi(1, int(parts[2]))
var player_pos: Vector3 = ctx.get("player_pos", Vector3.ZERO)
mob_spawned.emit(mob_vnum, count, player_pos)
return {
"ok": true,
"mob_vnum": mob_vnum,
"count": count,
"spawn_pos": player_pos,
"msg": "【GM】已在身旁召唤怪物 [vnum: %d, count: %d]" % [mob_vnum, count]
}
# /help
func _handle_help() -> Dictionary:
return {
"ok": true,
"commands": [
"/item <vnum> [count] - 生成物品",
"/level <1-105> - 设定等级",
"/gold <amount> - 注入金币",
"/set_skill <id> <1-40> - 设定技能等级(40=P级)",
"/map <village|sohan|desert|temple|flame|catacomb> - 传送至指定地图",
"/warp <x> <y> - 坐标瞬移",
"/spawn_mob <vnum> [count] - 刷怪/召唤首领"
],
"msg": "【GM指令帮助】输入对应命令快速调试单机环境!"
}