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:
@@ -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)
|
||||
Reference in New Issue
Block a user