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
+379
View File
@@ -0,0 +1,379 @@
# save_snapshot_system.gd —— Metin2 40250 单人单机角色全量快照存读档系统 1:1
# 对照 40250 服务端 input_db.cpp, ClientVS22/net/Player.h, char.h
class_name SaveSnapshotSystem
extends RefCounted
signal snapshot_saved(slot_index: int, filepath: String)
signal snapshot_loaded(slot_index: int, filepath: String)
signal snapshot_deleted(slot_index: int)
signal save_error(code: String, msg: String)
const SCHEMA_VERSION: String = "40250.1"
const DEFAULT_SAVE_DIR: String = "user://saves"
const DEFAULT_MAX_SLOTS: int = 5
const INVENTORY_SLOTS_COUNT: int = 90
const BELT_SLOTS_COUNT: int = 16
const EQUIPMENT_SLOTS_COUNT: int = 12
const JOB_NAMES: Dictionary = {
0: "战士",
1: "刺客",
2: "修罗",
3: "萨满"
}
# 校验快照结构与版本完整性
static func validate_snapshot(snapshot: Dictionary) -> Dictionary:
if not snapshot.has("meta"):
return {"ok": false, "reason": "MISSING_META", "msg": "缺失存档元信息 (meta)"}
var meta = snapshot["meta"]
if not meta.has("schema_version") or meta["schema_version"] != SCHEMA_VERSION:
return {"ok": false, "reason": "VERSION_MISMATCH", "msg": "存档版本不匹配!"}
if not snapshot.has("stats"):
return {"ok": false, "reason": "MISSING_STATS", "msg": "缺失角色基础属性 (stats)"}
if not snapshot.has("inventory"):
return {"ok": false, "reason": "MISSING_INVENTORY", "msg": "缺失背包物品数据 (inventory)"}
if not snapshot.has("equipment"):
return {"ok": false, "reason": "MISSING_EQUIPMENT", "msg": "缺失已装备防具与武器 (equipment)"}
return {"ok": true}
# 将游戏内实时角色对象/字典打包为可持久化全量快照 Dictionary
func create_snapshot(player_data: Dictionary, slot_index: int = 1) -> Dictionary:
var name: String = player_data.get("name", "NewHero")
var job: int = int(player_data.get("job", 0))
var level: int = int(player_data.get("level", 1))
var gold: int = int(player_data.get("gold", 0))
var playtime: int = int(player_data.get("playtime", 0))
var map_name: String = player_data.get("map_name", "village")
# 坐标序列化
var pos_val = player_data.get("pos", Vector3.ZERO)
var pos_arr: Array = [0.0, 0.0, 0.0]
if pos_val is Vector3:
pos_arr = [pos_val.x, pos_val.y, pos_val.z]
elif pos_val is Array and pos_val.size() >= 3:
pos_arr = [float(pos_val[0]), float(pos_val[1]), float(pos_val[2])]
# 背包序列化 (确保 90 格)
var raw_inv: Array = player_data.get("inventory", [])
var safe_inv: Array = []
for i in range(INVENTORY_SLOTS_COUNT):
if i < raw_inv.size() and raw_inv[i] != null:
safe_inv.append(raw_inv[i].duplicate(true))
else:
safe_inv.append(null)
# 装备栏序列化
var raw_equip: Dictionary = player_data.get("equipment", {})
var safe_equip: Dictionary = {}
for k in raw_equip.keys():
if raw_equip[k] != null:
safe_equip[str(k)] = raw_equip[k].duplicate(true)
else:
safe_equip[str(k)] = null
# 腰带与药水栏序列化
var belt_item = player_data.get("belt_item", null)
if belt_item != null:
belt_item = belt_item.duplicate(true)
var raw_belt_inv: Array = player_data.get("belt_inventory", [])
var safe_belt_inv: Array = []
for i in range(BELT_SLOTS_COUNT):
if i < raw_belt_inv.size() and raw_belt_inv[i] != null:
safe_belt_inv.append(raw_belt_inv[i].duplicate(true))
else:
safe_belt_inv.append(null)
# 时装槽序列化
var raw_costumes: Dictionary = player_data.get("costumes", {})
var safe_costumes: Dictionary = {}
for k in raw_costumes.keys():
if raw_costumes[k] != null:
safe_costumes[str(k)] = raw_costumes[k].duplicate(true)
else:
safe_costumes[str(k)] = null
# 技能数据序列化
var skills: Dictionary = player_data.get("skills", {}).duplicate(true)
# 龙石炼金数据
var alchemy: Dictionary = player_data.get("alchemy", {}).duplicate(true)
# 怪物卡片/成就/头衔
var monster_cards: Array = player_data.get("monster_cards", []).duplicate(true)
var achievements: Array = player_data.get("achievements", []).duplicate(true)
var active_title: String = player_data.get("active_title", "")
var snapshot: Dictionary = {
"meta": {
"schema_version": SCHEMA_VERSION,
"slot_index": slot_index,
"saved_at": int(Time.get_unix_time_from_system()),
"saved_datetime": Time.get_datetime_string_from_system()
},
"summary": {
"name": name,
"job": job,
"job_name": JOB_NAMES.get(job, "未知"),
"level": level,
"gold": gold,
"map_name": map_name,
"playtime": playtime
},
"stats": {
"name": name,
"job": job,
"level": level,
"exp": int(player_data.get("exp", 0)),
"hp": int(player_data.get("hp", 100)),
"max_hp": int(player_data.get("max_hp", 100)),
"sp": int(player_data.get("sp", 100)),
"max_sp": int(player_data.get("max_sp", 100)),
"stamina": int(player_data.get("stamina", 1000)),
"alignment": int(player_data.get("alignment", 0)),
"str": int(player_data.get("str", 15)),
"dex": int(player_data.get("dex", 15)),
"con": int(player_data.get("con", 15)),
"int_stat": int(player_data.get("int_stat", 15)),
"stat_points": int(player_data.get("stat_points", 0)),
"skill_points": int(player_data.get("skill_points", 0)),
"gold": gold,
"pos": pos_arr,
"map_name": map_name,
"playtime": playtime
},
"equipment": safe_equip,
"inventory": safe_inv,
"belt_system": {
"belt_item": belt_item,
"belt_inventory": safe_belt_inv
},
"costumes": safe_costumes,
"skills": skills,
"alchemy": alchemy,
"progression": {
"monster_cards": monster_cards,
"achievements": achievements,
"active_title": active_title
}
}
return snapshot
# 从全量快照反序列化回角色运行时字典
func restore_snapshot(snapshot: Dictionary) -> Dictionary:
var val_res = validate_snapshot(snapshot)
if not val_res["ok"]:
return val_res
var stats: Dictionary = snapshot["stats"]
var pos_arr: Array = stats.get("pos", [0.0, 0.0, 0.0])
var restored_pos := Vector3(pos_arr[0], pos_arr[1], pos_arr[2])
var restored: Dictionary = stats.duplicate(true)
restored["pos"] = restored_pos
# 恢复背包 (90 格)
var raw_inv: Array = snapshot.get("inventory", [])
var restored_inv: Array = []
for i in range(INVENTORY_SLOTS_COUNT):
if i < raw_inv.size() and raw_inv[i] != null:
restored_inv.append(raw_inv[i].duplicate(true))
else:
restored_inv.append(null)
restored["inventory"] = restored_inv
# 恢复装备栏 (转回 int 键)
var raw_equip: Dictionary = snapshot.get("equipment", {})
var restored_equip: Dictionary = {}
for k in raw_equip.keys():
var slot_id: int = int(k)
if raw_equip[k] != null:
restored_equip[slot_id] = raw_equip[k].duplicate(true)
else:
restored_equip[slot_id] = null
restored["equipment"] = restored_equip
# 恢复腰带
var belt_sys: Dictionary = snapshot.get("belt_system", {})
restored["belt_item"] = belt_sys.get("belt_item", null)
var raw_belt_inv: Array = belt_sys.get("belt_inventory", [])
var restored_belt_inv: Array = []
for i in range(BELT_SLOTS_COUNT):
if i < raw_belt_inv.size() and raw_belt_inv[i] != null:
restored_belt_inv.append(raw_belt_inv[i].duplicate(true))
else:
restored_belt_inv.append(null)
restored["belt_inventory"] = restored_belt_inv
# 恢复时装
var raw_costumes: Dictionary = snapshot.get("costumes", {})
var restored_costumes: Dictionary = {}
for k in raw_costumes.keys():
var slot_id: int = int(k)
if raw_costumes[k] != null:
restored_costumes[slot_id] = raw_costumes[k].duplicate(true)
else:
restored_costumes[slot_id] = null
restored["costumes"] = restored_costumes
# 恢复技能与炼金
restored["skills"] = snapshot.get("skills", {}).duplicate(true)
restored["alchemy"] = snapshot.get("alchemy", {}).duplicate(true)
# 恢复进度
var prog: Dictionary = snapshot.get("progression", {})
restored["monster_cards"] = prog.get("monster_cards", []).duplicate(true)
restored["achievements"] = prog.get("achievements", []).duplicate(true)
restored["active_title"] = prog.get("active_title", "")
return {
"ok": true,
"player_data": restored
}
# 获取存档槽位实际物理路径
func get_slot_path(slot_index: int, custom_dir: String = "") -> String:
var base_dir := custom_dir if not custom_dir.is_empty() else DEFAULT_SAVE_DIR
return base_dir.path_join("save_slot_%d.json" % slot_index)
# 保存指定槽位
func save_slot(slot_index: int, player_data: Dictionary, custom_dir: String = "") -> Dictionary:
var path := get_slot_path(slot_index, custom_dir)
var parent_dir := path.get_base_dir()
if not DirAccess.dir_exists_absolute(parent_dir):
var err := DirAccess.make_dir_recursive_absolute(parent_dir)
if err != OK:
save_error.emit("DIR_CREATE_FAILED", "无法创建存档目录:%s" % parent_dir)
return {"ok": false, "reason": "DIR_CREATE_FAILED", "path": path}
var snapshot := create_snapshot(player_data, slot_index)
var json_string := JSON.stringify(snapshot, "\t")
var file := FileAccess.open(path, FileAccess.WRITE)
if not file:
var err_code := FileAccess.get_open_error()
save_error.emit("FILE_WRITE_FAILED", "无法打开存档文件写入,错误码: %d" % err_code)
return {"ok": false, "reason": "FILE_WRITE_FAILED", "error": err_code, "path": path}
file.store_string(json_string)
file.close()
snapshot_saved.emit(slot_index, path)
return {
"ok": true,
"slot_index": slot_index,
"path": path,
"summary": snapshot["summary"],
"msg": "角色【%s】在槽位 %d 保存成功!" % [snapshot["summary"]["name"], slot_index]
}
# 加载指定槽位
func load_slot(slot_index: int, custom_dir: String = "") -> Dictionary:
var path := get_slot_path(slot_index, custom_dir)
if not FileAccess.file_exists(path):
save_error.emit("FILE_NOT_FOUND", "槽位 %d 的存档文件不存在!" % slot_index)
return {"ok": false, "reason": "FILE_NOT_FOUND", "path": path}
var file := FileAccess.open(path, FileAccess.READ)
if not file:
var err_code := FileAccess.get_open_error()
save_error.emit("FILE_READ_FAILED", "无法打开存档文件读取,错误码: %d" % err_code)
return {"ok": false, "reason": "FILE_READ_FAILED", "error": err_code, "path": path}
var content := file.get_as_text()
file.close()
var parse_result = JSON.parse_string(content)
if not (parse_result is Dictionary):
save_error.emit("JSON_PARSE_ERROR", "存档文件格式损坏或无法解析!")
return {"ok": false, "reason": "JSON_PARSE_ERROR", "path": path}
var restore_res = restore_snapshot(parse_result)
if not restore_res["ok"]:
save_error.emit(restore_res["reason"], restore_res["msg"])
return restore_res
snapshot_loaded.emit(slot_index, path)
return {
"ok": true,
"slot_index": slot_index,
"path": path,
"player_data": restore_res["player_data"],
"summary": parse_result.get("summary", {}),
"msg": "槽位 %d 角色【%s】读档成功!" % [slot_index, restore_res["player_data"]["name"]]
}
# 删除指定槽位存档
func delete_slot(slot_index: int, custom_dir: String = "") -> Dictionary:
var path := get_slot_path(slot_index, custom_dir)
if not FileAccess.file_exists(path):
return {"ok": false, "reason": "FILE_NOT_FOUND", "path": path}
var err := DirAccess.remove_absolute(path)
if err != OK:
save_error.emit("DELETE_FAILED", "删除槽位 %d 存档失败,错误码: %d" % [slot_index, err])
return {"ok": false, "reason": "DELETE_FAILED", "error": err, "path": path}
snapshot_deleted.emit(slot_index)
return {"ok": true, "slot_index": slot_index, "path": path, "msg": "槽位 %d 存档已清空。" % slot_index}
# 获取所有槽位概览信息 (无需加载全部背包即可在 UI 角色选择列表高效展示)
func get_slot_summaries(custom_dir: String = "", max_slots: int = DEFAULT_MAX_SLOTS) -> Array:
var summaries: Array = []
for i in range(1, max_slots + 1):
var path := get_slot_path(i, custom_dir)
if not FileAccess.file_exists(path):
summaries.append({
"slot_index": i,
"is_empty": true,
"path": path
})
continue
var file := FileAccess.open(path, FileAccess.READ)
if not file:
summaries.append({
"slot_index": i,
"is_empty": false,
"corrupted": true,
"path": path
})
continue
var content := file.get_as_text()
file.close()
var data = JSON.parse_string(content)
if data is Dictionary and data.has("summary"):
var sum = data["summary"].duplicate(true)
sum["slot_index"] = i
sum["is_empty"] = false
sum["saved_at"] = data.get("meta", {}).get("saved_at", 0)
sum["saved_datetime"] = data.get("meta", {}).get("saved_datetime", "")
sum["path"] = path
summaries.append(sum)
else:
summaries.append({
"slot_index": i,
"is_empty": false,
"corrupted": true,
"path": path
})
return summaries
# 导出为完整 JSON 字符串
func export_json(player_data: Dictionary, slot_index: int = 1, pretty: bool = true) -> String:
var snapshot := create_snapshot(player_data, slot_index)
return JSON.stringify(snapshot, "\t" if pretty else "")
# 从 JSON 字符串解析还原角色数据
func import_json(json_str: String) -> Dictionary:
var parsed = JSON.parse_string(json_str)
if not (parsed is Dictionary):
return {"ok": false, "reason": "INVALID_JSON_ROOT", "msg": "JSON 数据根节点必须为字典!"}
return restore_snapshot(parsed)