# 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)