Files
mtgodot-poc/project/save_snapshot_system.gd
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

380 lines
13 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)