344 lines
18 KiB
GDScript
344 lines
18 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 存档唯一实现:3 存档位 JSON 承载(激活槽写入/读回/版本迁移/损坏兜底/旧单档一次性迁移),
|
||
## Core 层零 UI 零玩法依赖(垂直切片验证:进度跨进程存活)。
|
||
## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。
|
||
## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。
|
||
## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。
|
||
## 拆件形态(D3 存档域):槽位文件域归 SlotStore(slots)、存档内元进度数据读写归 MetaStateStore
|
||
## (meta_state)——本类留磁盘 IO+版本迁移链+断点域+门面装配,消费方经 save.slots / save.meta_state 直连。
|
||
class_name SaveManagerCore
|
||
extends Node
|
||
|
||
const SAVE_FORMAT_VERSION := 7 # registry schema_version:v7=锻造工坊域(meta 新域 forge_routes〔六路线等级 levels+直购解锁清单 unlocked+当前武器/甲选中标记 weapon/armor;无特效域〕,旧档缺键由 _normalize 按默认补齐=初始持有态〔sword/light 在档余四锁、选中=初始路线〕,v6→v7 空迁移=缺键补齐模式);v6=生涯终身计数域(meta 两新域 adventure_total〔冒险总局数,开局即计含放弃与失败〕/adventure_cleared〔冒险成功局数,三关全通才计 1——判定字段 summary.cleared〕,旧档缺键由 _normalize 按默认补零,收束自增唯一入账=apply_adventure_summary);v5=天赋树域(meta 三新域 tree_nodes〔node_id→已购等级〕/fork_sides〔fork_group→a|b〕/migration_note〔三体系迁移补偿注记〕);v4=同调位域(meta 三新域 boss_kill_count/attunement_equipped/attune_refresh_level——旧档缺键 = 合法空态);v3=断点附随件扩列+摘要 keystone_drops;v2=断点域出生;v1=首版;高版本拒读 owner=存档序列化
|
||
const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前)
|
||
|
||
const TREE_STRUCT_VERSION := 2 # 树结构版本标记(12 节点 v0.8 结构;加载检测缺失
|
||
# 或更低 = 换代前结构 → 树域破坏性废弃重置〔契约 4:禁迁移禁折算〕)
|
||
|
||
const SLOT_COUNT := 3 # 存档位数写死 3(用户裁决 2——禁参数化扩位,扩位=新裁点另立)
|
||
const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派生模板(槽 1 禁复用旧单档名——Q1 乙前提防误读;探针面禁各自拼槽名,测试隔离走 attach_slot_template 注入口)
|
||
const LEGACY_SAVE_PATH := "user://save.json" # 旧隐式单档路径(Q1 甲:首次启动一次性字节迁入槽 1,此后无任何隐式读写)
|
||
const LEGACY_MIGRATED_SUFFIX := ".migrated.bak" # 迁移后旧档改名留痕后缀(字节级留盘不删——处置留痕)
|
||
|
||
var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写)
|
||
var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容)
|
||
var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;v1→v6 生产迁移链现行——_init 自注册)
|
||
var slots: SlotStore # 槽位文件域件(槽位挂载与模板/激活与建删/摘要窥视——架构 :89)
|
||
var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(写口原语+只读投影——架构 :90)
|
||
var _migration_fail_reason := "" # 迁移循环失败原因带出(_peek_migrated 返 null 时 load 侧落 corrupt 兜底文案单点)
|
||
|
||
|
||
func _init(path: String = "") -> void:
|
||
_data = _default_data()
|
||
slots = SlotStore.new(self, path)
|
||
meta_state = MetaStateStore.new(self)
|
||
# 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤)
|
||
register_migration(1, _migrate_v1_v2)
|
||
register_migration(2, _migrate_v2_v3)
|
||
register_migration(3, _migrate_v3_v4)
|
||
register_migration(4, _migrate_v4_v5)
|
||
register_migration(5, _migrate_v5_v6)
|
||
register_migration(6, _migrate_v6_v7)
|
||
|
||
|
||
# autoload 生命周期(SaveManager 为跨场景持有者,仿 RngService 先例:
|
||
# 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready,
|
||
# 读盘仍由调用方显式触发。
|
||
# 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、
|
||
# 无激活态(注入路径空,一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。
|
||
# 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。
|
||
func _ready() -> void:
|
||
slots.migrate_legacy_save(LEGACY_SAVE_PATH, slots.slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX)
|
||
|
||
|
||
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
|
||
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册),
|
||
# 冒烟经本口验证链式升级。
|
||
func register_migration(from_version: int, fn: Callable) -> void:
|
||
_migrations[from_version] = fn
|
||
|
||
|
||
# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)----
|
||
# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。
|
||
# 损坏兜底:原文件改名 <path>.corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。
|
||
func load_from_disk() -> bool:
|
||
_read_only = false
|
||
if not FileAccess.file_exists(slots.active_path()):
|
||
_data = _default_data() # 新档合法态
|
||
return true
|
||
var f := FileAccess.open(slots.active_path(), FileAccess.READ)
|
||
if f == null:
|
||
return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error()))
|
||
return _load_parsed(f)
|
||
|
||
|
||
## 读盘解析域(load_from_disk 主体段提取——解析/版本/迁移/规整;corrupt_fallback 调用点随段迁移,副作用序逐字)
|
||
func _load_parsed(f: FileAccess) -> bool:
|
||
var text := f.get_as_text()
|
||
f.close()
|
||
var parsed = JSON.parse_string(text)
|
||
if typeof(parsed) != TYPE_DICTIONARY:
|
||
return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)")
|
||
if not parsed.has("version"):
|
||
return _corrupt_fallback("缺版本字段")
|
||
var v := int(parsed["version"])
|
||
if v > SAVE_FORMAT_VERSION:
|
||
_read_only = true
|
||
_data = _default_data()
|
||
push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION])
|
||
return false
|
||
var fail := []
|
||
var migrated = _peek_migrated(parsed, fail)
|
||
if migrated == null:
|
||
return _corrupt_fallback(fail[0])
|
||
_data = _normalize(migrated)
|
||
# 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本)
|
||
if not _data["meta"].has("first_adventure_done"):
|
||
_data["meta"]["first_adventure_done"] = true
|
||
return true
|
||
|
||
|
||
# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)----
|
||
# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰);
|
||
# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。
|
||
func write_to_disk() -> bool:
|
||
if slots.active_path() == "":
|
||
push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)")
|
||
return false
|
||
if _read_only:
|
||
push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)")
|
||
return false
|
||
var tmp := slots.active_path() + ".tmp"
|
||
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
||
if f == null:
|
||
push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())])
|
||
return false
|
||
f.store_string(JSON.stringify(_data, "\t", true))
|
||
f.close()
|
||
if not rename_overwrite(tmp, slots.active_path()):
|
||
push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, slots.active_path()])
|
||
return false
|
||
return true
|
||
|
||
|
||
# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)----
|
||
# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。
|
||
|
||
func set_breakpoint(bp: Dictionary) -> void:
|
||
_data["breakpoint"] = bp.duplicate(true)
|
||
|
||
|
||
func clear_breakpoint() -> void:
|
||
_data["breakpoint"] = {}
|
||
|
||
|
||
func has_breakpoint() -> bool:
|
||
return not (_data.get("breakpoint", {}) as Dictionary).is_empty()
|
||
|
||
|
||
func breakpoint_snapshot() -> Dictionary:
|
||
return (_data.get("breakpoint", {}) as Dictionary).duplicate(true)
|
||
|
||
|
||
# ---- 私有:迁移链 / 损坏兜底 / 规整 / 文件原语 ----
|
||
|
||
# v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务)
|
||
func _migrate_v1_v2(data: Dictionary) -> Dictionary:
|
||
if not data.has("breakpoint"):
|
||
data["breakpoint"] = {}
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v2→v3:断点附随件扩列(bonus_taken/bonus_pending/bonus_result/
|
||
# keystone_drops + bonus.ap 轴)与摘要 additive 键 keystone_drops——旧档缺键
|
||
# = 合法空态(读取方 .get 兜底),无结构迁移体;版本号推进仅为 schema_version 对齐
|
||
func _migrate_v2_v3(data: Dictionary) -> Dictionary:
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v3→v4:同调位域(meta 三新域 boss_kill_count/attunement_equipped/
|
||
# attune_refresh_level,旧档缺键由 _normalize 按默认补齐,无结构迁移体;版本号推进仅为
|
||
# schema_version 对齐)
|
||
func _migrate_v3_v4(data: Dictionary) -> Dictionary:
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v4→v5:天赋树域(meta 三新域 tree_nodes/fork_sides/migration_note,缺键默认
|
||
# 补齐;开发阶段破坏性废弃口径:换代前结构由 _normalize 的 tree_struct 标记检测废弃重置,
|
||
# 本函数只做缺键补齐)。
|
||
func _migrate_v4_v5(data: Dictionary) -> Dictionary:
|
||
var meta: Dictionary[Variant, Variant] = data.get("meta", {}) as Dictionary
|
||
if not meta.has("tree_nodes"):
|
||
meta["tree_nodes"] = {}
|
||
if not meta.has("fork_sides"):
|
||
meta["fork_sides"] = {}
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v5→v6:生涯终身计数域(meta 两新域 adventure_total/adventure_cleared,旧档缺键
|
||
# 由 _normalize 按默认补零,无结构迁移体;版本号推进仅为 schema_version 对齐。
|
||
# 幂等性:重复迁移只覆写 version,既有值零改动;加载后 _normalize 补齐缺省——旧档可载可玩)
|
||
func _migrate_v5_v6(data: Dictionary) -> Dictionary:
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v6→v7:锻造工坊域(meta 新域 forge_routes,旧档缺键由 _normalize 按默认补齐=
|
||
# 初始持有态〔开发阶段无迁移禁折算——契约 4〕,无结构迁移体;版本号推进仅为 schema_version 对齐
|
||
# 幂等性:重复迁移只覆写 version,既有值零改动;加载后 _normalize 补齐缺省——旧档可载可玩)
|
||
func _migrate_v6_v7(data: Dictionary) -> Dictionary:
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# 迁移循环提取(load/peek 两调用方同构共享):成功=迁移完成 Dictionary;失败=null
|
||
# (原因经 fail 数组带出——load 侧落 corrupt 兜底文案、peek 侧保持「返回未填充 out」逐字)
|
||
func _peek_migrated(parsed: Variant, fail: Array) -> Variant:
|
||
var v := int(parsed["version"])
|
||
while v < SAVE_FORMAT_VERSION:
|
||
if not _migrations.has(v):
|
||
fail.append("迁移链缺步骤 %d→%d" % [v, v + 1])
|
||
return null
|
||
var stepped = _migrations[v].call(parsed)
|
||
if typeof(stepped) != TYPE_DICTIONARY:
|
||
fail.append("迁移步骤 %d→%d 执行失败" % [v, v + 1])
|
||
return null
|
||
parsed = stepped
|
||
v = int(parsed.get("version", v + 1))
|
||
return parsed
|
||
|
||
|
||
## 摘要窥视内核(只读推演单点——SlotStore 两 peek 方法经槽路径派生委托至此;
|
||
## 迁移链在此只读推演——生产迁移 Callable 为纯函数,零盘面副作用)
|
||
func peek_summary_at(path: String) -> Dictionary:
|
||
var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0}
|
||
if not FileAccess.file_exists(path):
|
||
out["readable"] = true # 空槽=可读零值合法态
|
||
return out
|
||
out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分)
|
||
var f := FileAccess.open(path, FileAccess.READ)
|
||
if f == null:
|
||
return out
|
||
var text := f.get_as_text()
|
||
f.close()
|
||
var parsed = JSON.parse_string(text)
|
||
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"):
|
||
return out
|
||
if int(parsed["version"]) > SAVE_FORMAT_VERSION:
|
||
return out
|
||
var fail := []
|
||
var migrated = _peek_migrated(parsed, fail)
|
||
if migrated == null:
|
||
return out
|
||
var norm := _normalize(migrated)
|
||
out["readable"] = true
|
||
out["keystone"] = int(norm["meta"].get("keystone_balance", 0))
|
||
out["cleared"] = int(norm["meta"].get("adventure_cleared", 0))
|
||
out["total"] = int(norm["meta"].get("adventure_total", 0))
|
||
return out
|
||
|
||
|
||
# ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)----
|
||
|
||
func _default_data() -> Dictionary[String, Variant]:
|
||
return {
|
||
"version": SAVE_FORMAT_VERSION,
|
||
"meta": {
|
||
"keystone_balance": 0,
|
||
"item_unlocks": {},
|
||
"level_clear_counts": {"1": 0, "2": 0, "3": 0},
|
||
"boss_first_kill": {},
|
||
"boss_kill_count": 0,
|
||
"attunement_equipped": [],
|
||
"attune_refresh_level": 0,
|
||
"tree_nodes": {}, # 天赋树已购等级(node_id→int)
|
||
"fork_sides": {}, # Fork 激活态(fork_group→"a"|"b"——一节点双态切换承载)
|
||
"tree_struct": TREE_STRUCT_VERSION, # 树结构版本标记(缺失=换代前结构,加载废弃重置)
|
||
"recent_summaries": [],
|
||
"first_adventure_done": false,
|
||
"adventure_total": 0, # 生涯终身计数·冒险总局数(开局即计含放弃与失败,收束唯一入账)
|
||
"adventure_cleared": 0, # 生涯终身计数·冒险成功局数(三关全通才计 1——判定字段 summary.cleared)
|
||
"vision_used": 0, # 生涯终身计数·真视类工具累计使用次数(信标网络解锁行为条件;
|
||
# 受理成功唯一计数点=battle 层经冒险层聚合、收束入账;缺键补零不升版本)
|
||
"forge_routes": { # 锻造工坊域(六路线等级+直购解锁清单+当前选中标记;无特效域)
|
||
"levels": {"sword": 0, "spear": 0, "staff": 0, "light": 0, "heavy": 0, "ward": 0},
|
||
"unlocked": [], # 直购解锁路线 id 清单(初始持有与练满前一档=派生解锁不落档)
|
||
"weapon": "sword", # 当前选中武器路线(初始持有=sword)
|
||
"armor": "light", # 当前选中甲路线(初始持有=light)
|
||
},
|
||
},
|
||
"breakpoint": {}, # 冒险断点快照(空 = 无断点合法态——「继续上局」隐藏)
|
||
}
|
||
|
||
|
||
# 原地重置默认态(SlotStore 槽位原语的协作口——激活/建删/换注回默认数据;
|
||
# 公开化〔5.1 封装修复——SlotStore 反向私取承接,_reset_to_default 更名非并存〕)
|
||
func reset_to_default() -> void:
|
||
_data = _default_data()
|
||
|
||
|
||
# 禁写位公开面(SlotStore 激活/换注三态恢复消费——一行转发自有成员,零逻辑增量)
|
||
func set_read_only(v: bool) -> void:
|
||
_read_only = v
|
||
|
||
|
||
func read_only() -> bool:
|
||
return _read_only
|
||
|
||
|
||
# 数据快照还原公开面(SlotStore 激活/新建失败回退三态恢复消费——零拷贝语义:snapshot 返 _data
|
||
# 本引用、restore 直还引用,行为与原 _data 直读写逐位等价)
|
||
func data_snapshot() -> Dictionary[String, Variant]:
|
||
return _data
|
||
|
||
|
||
func restore_data(data: Dictionary[String, Variant]) -> void:
|
||
_data = data
|
||
|
||
|
||
# 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。
|
||
func _corrupt_fallback(reason: String) -> bool:
|
||
var bak := slots.active_path() + ".corrupt.bak"
|
||
if not rename_overwrite(slots.active_path(), bak):
|
||
push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason])
|
||
else:
|
||
push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason])
|
||
_data = _default_data()
|
||
return true
|
||
|
||
|
||
# 迁移后/低版本档字段补齐 + 退役域清退(改版不弃档:缺新域按默认补齐,既有值保留;
|
||
# 退役域按默认模型键集白名单收口——换代退役键〔forge_levels/migration_note 等树域旧件〕不流入
|
||
# 内存不回写盘,开发阶段破坏性废弃口径〔禁迁移禁折算〕)。
|
||
# 树结构检查先于缺键补齐:换代前结构(tree_struct 标记缺失或更低)→ 树域废弃重置——
|
||
# tree_nodes/fork_sides 清空、钥石余额等他域零触碰(V16 断言面)。
|
||
func _normalize(data: Dictionary) -> Dictionary[String, Variant]:
|
||
var merged := _default_data()
|
||
var def_meta: Dictionary[Variant, Variant] = merged["meta"] # 补齐源引用(先于 data 覆盖取出——data 自带 meta 时 merged["meta"] 被换走,此引用仍指默认值)
|
||
for key in data:
|
||
merged[key] = data[key]
|
||
var meta: Dictionary[Variant, Variant] = merged.get("meta", {})
|
||
if int(meta.get("tree_struct", 0)) < TREE_STRUCT_VERSION:
|
||
meta["tree_nodes"] = {}
|
||
meta["fork_sides"] = {}
|
||
var clean_meta := {}
|
||
for key in def_meta:
|
||
clean_meta[key] = meta.get(key, def_meta[key])
|
||
merged["meta"] = clean_meta
|
||
return merged
|
||
|
||
|
||
# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义;IO 原语单点——
|
||
# SlotStore 迁移流经本件调用)
|
||
func rename_overwrite(from: String, to: String) -> bool:
|
||
var dir := DirAccess.open(from.get_base_dir())
|
||
if dir == null:
|
||
return false
|
||
if dir.file_exists(to.get_file()) and dir.remove(to.get_file()) != OK:
|
||
return false
|
||
return dir.rename(from.get_file(), to.get_file()) == OK
|