From b687ab153d4c90324e5c3afa9e2254a48e02c578 Mon Sep 17 00:00:00 2001 From: chen Date: Sun, 9 Aug 2026 19:17:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=A4=E7=BA=A7=E5=AD=98=E6=A1=A3?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E2=80=94=E2=80=94=E6=97=A0=E9=99=90=E7=BB=84?= =?UTF-8?q?/=E6=A7=BD=EF=BC=8C=E5=BB=BA=E7=BB=84=E5=BB=BA=E6=A7=BD/?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A1=A3/=E4=B8=A4=E7=BA=A7=E6=B5=8F?= =?UTF-8?q?=E8=A7=88/=E8=AF=BB=E6=A1=A3=E8=BF=98=E5=8E=9F/=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E7=BB=84=E4=B8=8E=E6=A7=BD/=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=86=85=E5=8A=A0=E8=BD=BD=EF=BC=88D8=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/design/存档系统设计.md | 210 +++++++++++++++++++++ doc/开发计划表.md | 4 +- project.godot | 1 + src/Character/Disciple.gd | 42 +++++ src/Character/DiscipleManager.gd | 9 + src/Core/GameState.gd | 22 ++- src/Core/MainGame.gd | 33 +++- src/Core/MainGame.tscn | 5 + src/Core/SaveSystem.gd | 286 +++++++++++++++++++++++++++++ src/Core/SaveSystem.gd.uid | 1 + src/Core/SectManager.gd | 15 ++ src/Sect/buildings/Building.gd | 12 ++ src/UI/HUD/TopBarHUD.tscn | 7 + src/UI/HUD/top_bar_hud.gd | 6 + src/UI/Panels/MainMenu/MainMenu.gd | 9 +- src/UI/Panels/SavePanel.gd | 166 ++++++++++++++++- src/UI/Panels/SavePanel.tscn | 30 +-- src/UI/Panels/SaveSlot.gd | 55 +++--- src/UI/Panels/SaveSlot.tscn | 1 + 19 files changed, 851 insertions(+), 63 deletions(-) create mode 100644 doc/design/存档系统设计.md create mode 100644 src/Core/SaveSystem.gd create mode 100644 src/Core/SaveSystem.gd.uid diff --git a/doc/design/存档系统设计.md b/doc/design/存档系统设计.md new file mode 100644 index 0000000..63fc109 --- /dev/null +++ b/doc/design/存档系统设计.md @@ -0,0 +1,210 @@ +# 存档系统设计(D8:存档接入) + +> 对应开发计划表 D8(8/10):① SaveSystem JSON 序列化 ② 存/读接口 ③ 替换 SavePanel 测试数据。 +> 交付标准:读档后数据还原。本文档只描述设计,不包含代码。 +> 状态:全部步骤已实现(SaveSystem/序列化/GameState+MainGame 分支/SavePanel 双模式/自动存档/两级浏览/读档还原/删除与容错/游戏内加载进度点/删除存档组),验收 1–9 全部通过 + +--- + +## 一、两级存档结构(无限组 / 无限槽) + +**第一级:存档组 SaveGroup(一个角色/一次轮回,UI 显示"存档N")** → **第二级:存档槽 SaveSlot(该组下任意多个进度快照)** + +- **每个新游戏 → 创建一个 SaveGroup**(主菜单"新游戏"时自动生成,id 自增) +- **每次点击存档 → 在所属 SaveGroup 下创建一个新 SaveSlot**(不覆盖、不固定槽位) +- **组和槽的数量均无上限** + +``` +user://saves/ +├── profiles.json # 存档组列表 [{id, name, created, last_date}] +├── profile_1/ # 存档组 1 +│ ├── auto.json # 自动存档(返回主菜单时写入,特殊槽) +│ ├── slot_1.json # 手动槽(组内序号递增命名) +│ └── slot_2.json +└── profile_2/ ... +``` + +- 槽位命名:**组内序号递增**(`slot_1`、`slot_2`…),同一组内永不重名、互不覆盖;删除槽位后序号从当前最大 +1 继续(不重用) +- 自动槽 `auto.json` 固定名(续档用) + +## 二、可存档数据清单 + +| 系统 | 数据 | 说明 | +|------|------|------| +| GameState | `year/month/day` | 游戏日期(TimeSystem.set_date 恢复) | +| SectManager | `spirit_stones` / `reputation` / `food` | 三资源 | +| DiscipleManager | `disciples` + `_next_id` | 名录;候选池不存 | +| BuildingManager | `buildings` | 建筑类型/等级 | + +> 修为/境界/饥饿天数都在 Disciple 字段内,随名录序列化。 + +## 三、SaveSystem(新 Autoload) + +存放位置:`src/Core/SaveSystem.gd`,注册于 project.godot(依赖各管理器,声明在最后)。 + +| 接口 | 说明 | +|------|------| +| `create_profile() -> int` | 新建存档组,返回 id(名"存档N") | +| `get_profiles() -> Array[Dictionary]` | 存档组列表(id/name/created/last_date) | +| `save_game(profile_id) -> String` | **新建一个槽位**并写入当前游戏数据,返回槽文件名;失败返回空串 | +| `save_auto(profile_id) -> bool` | 写自动槽(返回主菜单时调用) | +| `get_slots(profile_id) -> Array[Dictionary]` | 该组全部槽位(槽文件名 + 元数据),按时间升序 | +| `load_game(profile_id, slot_name) -> bool` | 读取槽位 JSON → 还原各系统 | +| `delete_profile(profile_id) -> bool` | 删除存档组(列表移除 + 递归删目录含全部槽位) | +| `delete_slot(profile_id, slot_name) -> bool` | 删除槽位文件 | +| `reset()` | 清空内存游戏数据(各管理器 reset + 资源清零) | + +## 四、序列化设计 + +### 1. 数据模型加 to_dict/from_dict + +- `Disciple.to_dict()` / `static Disciple.from_dict(data)`(14 字段全量,枚举转 int) +- `Building.to_dict()` / `static from_dict(data)`(2 字段) + +### 2. 槽位 JSON 结构(slot_1754734800.json) + +```json +{ + "version": 1, + "profile_id": 1, + "saved_date": {"year": 3, "month": 5, "day": 12}, + "date": {"year": 3, "month": 5, "day": 12}, + "resources": {"spirit_stones": 1230, "reputation": 45, "food": 210}, + "next_disciple_id": 7, + "disciples": [ + {"id": 1, "name": "林清玄", "age": 16, "aptitude": 2, "roots": [0, 1], + "is_heavenly": false, "join_year": 1, "join_month": 1, "join_day": 1, + "realm_index": 0, "sub_realm_index": 4, "cultivation_exp": 512.5, "hunger_days": 0} + ], + "buildings": [ + {"type": 0, "level": 2}, + {"type": 1, "level": 1} + ] +} +``` + +- `saved_date`/`resources` 顶层冗余字段 = 槽位元数据(列表显示用,免解析全档) +- `version` 为格式版本,预留兼容 + +### 3. profiles.json 结构 + +```json +{"profiles": [{"id": 1, "name": "存档1", "created": "2026-08-10", "last_date": "3年5月12日"}]} +``` + +## 五、存档/读档流程 + +### 存档(游戏内,每次=新建槽) + +1. TopBar 新增"存档"按钮 → SavePanel(存档模式,时间暂停) +2. 左侧显示存档组(当前组高亮),右侧显示该组全部槽位 +3. 点"新建存档"按钮 → `save_game(profile_id)` 创建新槽 → 列表新增一条 +4. 关闭面板恢复时间 + +### 读档(主菜单,两级浏览) + +1. 主菜单"读取存档" → SavePanel(读档模式) +2. **左侧存档组列表**(存档N + 最后日期)→ 点组 → **右侧显示该组槽位列表**(自动+手动,元数据齐全) +3. 点槽位 → `GameState.request_load_game(profile_id, slot_name)` → 进入 MainGame + +### MainGame 分支(改造 _ready) + +``` +NEW_GAME → 现有流程:start_new_game + 开局资源 + 招募弹窗(暂停) +LOAD_GAME → SaveSystem.load_game(profile_id, slot_name) → 还原 → 不弹窗、不暂停 +``` + +### 自动存档 + +TopBar"返回"主菜单时(sign_return_mainmenu 处理前)→ `save_auto(profile_id)` 自动槽——保证"退→再进"能续档。 + +## 六、UI:SavePanel(复用现有手绘界面) + +``` +SavePanel (PanelContainer 全屏) +└── VBoxContainer + ├── Header: TitleLabel("选择存档") + Spacer + Button_Esc("返回") + ├── 存档模式:Button_NewSlot("新建存档") ← 每次点击创建新槽位 + └── ScrollContainer + └── HBoxContainer + ├── SaveGroup 区(VBoxContainer) ← 左侧:存档组按钮列表(动态生成"存档N") + └── SlotList 区(VBoxContainer) ← 右侧:该组槽位条目列表(动态生成 SaveSlot 实例) +``` + +**SaveSlot 条目**(复用现有场景,无空槽概念——每个条目都是真实存档): + +| 节点 | 填充内容 | +|------|---------| +| SlotIndexLabel | 序号(自动槽=0,手动槽按时间序 1..N) | +| SaveNameLabel | "自动存档" / "存档 N" | +| MetaLine1 | "X年X月X日 弟子N人" | +| MetaLine2 | "灵石 M" | +| DeleteButton | 删除该槽位(删除前确认) | +| ArrowIcon | 读档箭头(点击读档) | + +**交互规则**: + +| 项 | 存档模式(游戏内) | 读档模式(主菜单) | +|----|-------------------|-------------------| +| 左侧组列表 | 当前组高亮(可切换查看,**组行带"删"按钮,当前组禁用**) | 可切换任意组(组行带"删"按钮) | +| 槽位点击 | **回到该进度点**(加载所选槽,未保存进度丢失) | 点击读档进入游戏 | +| 删除按钮 | 可删(确认后) | 可删(确认后) | +| 暂停 | 打开时暂停,关闭恢复 | 不暂停 | + +## 七、分步实现规划(小步可验证,每步独立跑通) + +### 步骤 1:SaveSystem 骨架(新 Autoload)✅ + +| 项 | 内容 | +|----|------| +| 改动 | 新建 `src/Core/SaveSystem.gd`:`create_profile()`(建组,名"存档N")、`get_profiles()`、`save_game(profile_id)`(新建槽,文件名 `slot_{组内序号}.json`,返回槽名)、`save_auto(profile_id)`、`get_slots(profile_id)`(自动槽最前+手动按序号升序,含元数据)、`load_game(profile_id, slot_name)`、`delete_slot()`、`reset()`;`project.godot` 注册(声明在最后) | +| 验证 | ✅ 建组(存档N 自增)→ 存档 → slot_1/2/3 生成 → `get_slots` 读回;删除后序号不重用 | + +### 步骤 2:数据模型序列化 ✅ + +| 项 | 内容 | +|----|------| +| 改动 | `Disciple.to_dict()/static from_dict()`(14 字段,枚举转 int);`Building.to_dict()/static from_dict()`;`DiscipleManager` 加 `get/set_next_disciple_id()`;`SectManager` 加 `set_spirit_stones/set_reputation/set_food()`(带信号,读档后 HUD 自动刷新) | +| 验证 | ✅ 存档文件内容完整(弟子/建筑/资源/日期全量) | + +### 步骤 3:GameState + MainGame 分支 🟡(GameState 已改,MainGame 分支待接) + +| 项 | 内容 | +|----|------| +| 改动 | `GameState` 加 `profile_id: int`、`save_slot: String`(槽文件名);`request_new_game(pid)`、`request_load_game(pid, slot_name)`;`MainGame._ready` 分支 NEW_GAME(原流程)/ LOAD_GAME(`load_game` 还原,不弹窗不暂停,失败回退新游戏);`sign_return_mainmenu` 前 `save_auto(profile_id)` | +| 验证 | 无头:存→清→读闭环,日期/资源/名录(境界修为饥饿)/建筑逐项一致 | + +### 步骤 4:SavePanel 动态填充(复用现有手绘界面)✅ + +| 项 | 内容 | +|----|------| +| 改动 | `SavePanel.gd` 双模式:存档模式(游戏内,暂停,当前组高亮 + 槽位列表 + "新建存档"按钮 → `save_game` → 列表新增);读档模式(主菜单,左列组列表 → 点组 → 右列槽位列表,点槽读档)。`SaveSlot.gd` 填充:序号/名称/MetaLine1/MetaLine2/删除(确认后删)/箭头(读档)。TopBar 加"存档"按钮 + `sign_open_save` 信号 | +| 验证 | ✅ 存档模式:打开暂停 → 新建存档 → 槽位生成(内容完整)→ 关闭恢复;读档模式待 F5 手测 | + +### 步骤 5:接线闭环 🟡(MainMenu 建组已接,读档模式接线+自动存档待做) + +| 项 | 内容 | +|----|------| +| 改动 | `MainMenu`"新游戏"→ `create_profile()` 建组 → 进游戏;"读取存档"→ 面板读档模式;`MainGame.tscn` 挂 SavePanel 实例 | +| 验证 | 完整闭环:新游戏(建组)→ 招弟子 → 挂机 → 存档(建槽)→ 返回(自动槽)→ 主菜单读档 → 数据还原 | + +### 步骤 6:整体验证 + 提交 + +| 项 | 内容 | +|----|------| +| 改动 | 无(仅验证) | +| 验证 | 对照八节验收清单逐项勾选;git 提交 | + +> 依赖关系:1→2 顺序执行(序列化被 SaveSystem 消费);3 依赖 1/2;4 依赖 1/3;5 依赖 4;6 为最终验证。 + +## 八、验收清单(D8 交付标准) + +- [x] 主菜单"新游戏"自动创建新存档组(存档N) +- [x] 游戏内"存档"面板点"新建存档"→ 该组新增一条槽位(文件生成,内容完整) +- [x] 连续多次存档 → 同一组内多个槽位互不覆盖(序号命名,已验证 slot_1/2/3 内容独立) +- [x] 返回主菜单自动写自动槽(auto.json) +- [x] "读取存档"两级浏览:左侧组列表 → 右侧槽位列表(元数据显示) +- [x] 读档后:日期/资源/名录(含境界/修为/饥饿)/建筑全部还原 +- [x] 读档进入**不弹招募窗、不暂停** +- [x] 删除槽位后列表刷新;损坏/缺失 JSON 不崩溃(回退提示) +- [x] 新游戏流程不受影响(仍弹开局招募);多组多槽共存 diff --git a/doc/开发计划表.md b/doc/开发计划表.md index f1d643a..76a0665 100644 --- a/doc/开发计划表.md +++ b/doc/开发计划表.md @@ -1,7 +1,7 @@ # 开发计划表 > 开始日期:2026-08-03(周一) 每日有效开发约 2 小时 -> 进度更新:2026-08-09  D1–D6 全部完成(D6 含口粮/饥饿/离宗方案B),D7 补漏完成;D8(存档)明日进行 +> 进度更新:2026-08-10  D1–D8 全部完成(D8 两级存档系统验收 9/9),D9(打磨)进行中 ## 一、每日作息模板(工作日) @@ -26,7 +26,7 @@ | 8/7 五 | D5 | 修炼循环 | ① 修为随时间增长(资质×灵气) ② 境界门槛 ③ 突破判定 | 弟子自动修炼,可突破 | ✅ 完成(修为逐日增长/门槛升层/瓶颈按层概率判定/13层圆满封顶/修为进度显示,配置驱动) | | 8/8 六 | D6 | 建筑系统基础 | ① 灵石矿场景 ② 建造→每时辰产出 ③ 升级 | 建筑产灵石,升级加速 | ✅ 完成(步骤1-7:食物/开局赠送/建筑参数配置/Building+BuildingManager/建筑面板+资源按钮/口粮饥饿离宗方案B,验收11/11) | | 8/9 日 | D7 | 休息 / 补漏 | 修本周 bug,整理代码 | git 干净 | ✅ 完成(修复:管理面板关闭冲掉手动暂停) | -| 8/10 一 | D8 | 存档接入 | ① SaveSystem JSON 序列化 ② 存/读接口 ③ 替换 SavePanel 测试数据 | 读档后数据还原 | ⬜ | +| 8/10 一 | D8 | 存档接入 | ① SaveSystem JSON 序列化 ② 存/读接口 ③ 替换 SavePanel 测试数据 | 读档后数据还原 | ✅ 完成(两级存档:无限组/槽、建组建槽/自动档/两级浏览/读档全量还原/删除组与槽/游戏内加载进度点/损坏容错,验收9/9) | | 8/11 二 | D9 | 打磨 | 数值平衡初调,玩 10 分钟验证 | 无致命 bug | ⬜ | | 8/12 三 | D10 | **M1 验收** | 完整跑一遍:新游戏→招弟子→挂机→存→退→读 | 闭环可玩 | ⬜ | diff --git a/project.godot b/project.godot index 42ceff1..a2de9ce 100644 --- a/project.godot +++ b/project.godot @@ -24,6 +24,7 @@ GameConfig="*res://src/Core/GameConfig.gd" DiscipleManager="*res://src/Character/DiscipleManager.gd" CultivationManager="*res://src/Character/CultivationManager.gd" BuildingManager="*res://src/Sect/BuildingManager.gd" +SaveSystem="*res://src/Core/SaveSystem.gd" [display] diff --git a/src/Character/Disciple.gd b/src/Character/Disciple.gd index ed1ecd1..b28b8fe 100644 --- a/src/Character/Disciple.gd +++ b/src/Character/Disciple.gd @@ -60,6 +60,48 @@ func get_join_date_text() -> String: return "" return "%d年%d月%d日" % [join_year, join_month, join_day] +## 序列化为字典(D8 存档用,枚举转 int)。 +func to_dict() -> Dictionary: + var roots_data: Array[int] = [] + for r in roots: + roots_data.append(int(r)) + return { + "id": id, + "name": name, + "age": age, + "aptitude": int(aptitude), + "roots": roots_data, + "is_heavenly": is_heavenly, + "join_year": join_year, + "join_month": join_month, + "join_day": join_day, + "realm_index": realm_index, + "sub_realm_index": sub_realm_index, + "cultivation_exp": cultivation_exp, + "hunger_days": hunger_days, + } + +## 从字典还原(D8 读档用)。 +## @param data: to_dict 产生的字典 +static func from_dict(data: Dictionary) -> Disciple: + var d := Disciple.new() + d.id = int(data["id"]) + d.name = str(data["name"]) + d.age = int(data["age"]) + d.aptitude = int(data["aptitude"]) as Aptitude + d.roots = [] + for r in data["roots"]: + d.roots.append(int(r) as SpiritRoot) + d.is_heavenly = bool(data["is_heavenly"]) + d.join_year = int(data["join_year"]) + d.join_month = int(data["join_month"]) + d.join_day = int(data["join_day"]) + d.realm_index = int(data["realm_index"]) + d.sub_realm_index = int(data["sub_realm_index"]) + d.cultivation_exp = float(data["cultivation_exp"]) + d.hunger_days = int(data["hunger_days"]) + return d + ## 本层修炼进度文本,如 "1234 / 2000"(当前修为 / 本层门槛)。 ## 练气圆满(13 层)返回"练气圆满";非练气期返回空串。 func get_cultivation_progress_text() -> String: diff --git a/src/Character/DiscipleManager.gd b/src/Character/DiscipleManager.gd index 21ef852..3963af0 100644 --- a/src/Character/DiscipleManager.gd +++ b/src/Character/DiscipleManager.gd @@ -110,6 +110,15 @@ func reset() -> void: _days_until_recruit = _get_interval_days() disciples_changed.emit() +## 当前下一个弟子 ID(D8 存档用)。 +func get_next_disciple_id() -> int: + return _next_id + +## 设置下一个弟子 ID(D8 读档用)。 +## @param value: 下一个 ID +func set_next_disciple_id(value: int) -> void: + _next_id = value + ## 定期招募间隔(游戏天数),由 GameConfig 的"年"换算而来。 func _get_interval_days() -> int: var days_per_year := TimeSystem.MONTHS_PER_YEAR * TimeSystem.DAYS_PER_MONTH diff --git a/src/Core/GameState.gd b/src/Core/GameState.gd index d35dcb1..e5a36be 100644 --- a/src/Core/GameState.gd +++ b/src/Core/GameState.gd @@ -7,24 +7,30 @@ enum GameMode { NEW_GAME, LOAD_GAME } ## 当前进入游戏的方式。 var mode: GameMode = GameMode.NEW_GAME -## 要加载的存档 ID(仅 LOAD_GAME 时有效)。 -var save_id: String = "" +## 当前存档组 ID(两级存档第一级,SaveSystem 分配)。 +var profile_id: int = 0 +## 要加载的槽位文件名(仅 LOAD_GAME 时有效,如 "auto"/"slot_1754734800")。 +var save_slot: String = "" # 游戏内时间(12 月 × 30 天 = 360 天/年),实际推进由 TimeSystem 负责 var year: int = 1 var month: int = 1 var day: int = 1 -## 请求开始新游戏:重置模式与时间。 -func request_new_game() -> void: +## 请求开始新游戏。 +## @param pid: 新存档组 ID(主菜单创建) +func request_new_game(pid: int) -> void: mode = GameMode.NEW_GAME - save_id = "" + profile_id = pid + save_slot = "" year = 1 month = 1 day = 1 ## 请求加载指定存档。 -## @param id: 存档 ID -func request_load_game(id: String) -> void: +## @param pid: 存档组 ID +## @param slot_name: 槽位文件名(不含 .json) +func request_load_game(pid: int, slot_name: String) -> void: mode = GameMode.LOAD_GAME - save_id = id + profile_id = pid + save_slot = slot_name diff --git a/src/Core/MainGame.gd b/src/Core/MainGame.gd index 256ac8f..0f8971d 100644 --- a/src/Core/MainGame.gd +++ b/src/Core/MainGame.gd @@ -7,16 +7,29 @@ func _ready() -> void: add_to_group("game_scene") # 应用用户设置中的默认流速 TimeSystem.apply_default_speed() - # 开局招募:清空旧数据 → 生成 5 名候选 → 暂停时间等待选择(读档流程接入后由存档还原) - DiscipleManager.start_new_game() - # 开局建筑:清空旧建筑(读档流程接入后由存档还原) - BuildingManager.reset() - # 开局赠送资源(数量来自配置;读档流程接入后由存档还原) - SectManager.add_spirit_stones(GameConfig.starting_spirit_stones) - SectManager.add_food(GameConfig.starting_food) + match GameState.mode: + GameState.GameMode.NEW_GAME: + _start_new_game() + GameState.GameMode.LOAD_GAME: + # 读档还原(含日期/资源/名录/建筑);失败回退新游戏 + if not SaveSystem.load_game(GameState.profile_id, GameState.save_slot): + push_warning("读档失败,回退新游戏") + _start_new_game() -## 顶部 HUD 的返回主菜单请求。 +## 新游戏初始化:清空旧数据 → 开局招募弹窗(暂停)→ 设定开局资源。 +func _start_new_game() -> void: + # 开局招募:清空旧数据 → 生成 5 名候选 → 暂停时间等待选择 + DiscipleManager.start_new_game() + # 开局建筑:清空旧建筑 + BuildingManager.reset() + # 开局资源:直接设定(set 而非 add,避免与上次会话残留混淆) + SectManager.set_spirit_stones(GameConfig.starting_spirit_stones) + SectManager.set_food(GameConfig.starting_food) + SectManager.set_reputation(0) + +## 顶部 HUD 的返回主菜单请求(先写自动存档)。 func _on_top_bar_hud_sign_return_mainmenu() -> void: + SaveSystem.save_auto(GameState.profile_id) get_tree().change_scene_to_file("res://src/UI/Panels/MainMenu/MainMenu.tscn") ## 顶部 HUD 的"管理"按钮请求:打开弟子名录面板。 @@ -26,3 +39,7 @@ func _on_top_bar_hud_sign_open_disciples() -> void: ## 顶部 HUD 的"资源"按钮请求:打开建筑面板。 func _on_top_bar_hud_sign_open_buildings() -> void: $BuildingsPanel.show_panel() + +## 顶部 HUD 的"存档"按钮请求:打开存档面板(存档模式)。 +func _on_top_bar_hud_sign_open_save() -> void: + $SavePanel.open_save() diff --git a/src/Core/MainGame.tscn b/src/Core/MainGame.tscn index b95ffc9..42b2436 100644 --- a/src/Core/MainGame.tscn +++ b/src/Core/MainGame.tscn @@ -5,6 +5,7 @@ [ext_resource type="PackedScene" uid="uid://dl0tb68vsw3c6" path="res://src/UI/HUD/TopBarHUD.tscn" id="1_g6xmk"] [ext_resource type="PackedScene" uid="uid://cahbpjpnmbcfv" path="res://src/UI/Panels/RecruitPanel/RecruitPanel.tscn" id="1_k5nrm"] [ext_resource type="PackedScene" path="res://src/UI/Panels/BuildingsPanel/BuildingsPanel.tscn" id="1_b9dpm"] +[ext_resource type="PackedScene" uid="uid://dj7b5vssydifa" path="res://src/UI/Panels/SavePanel.tscn" id="1_s4vpm"] [ext_resource type="Texture2D" uid="uid://cweu17mwblxmg" path="res://assets/sprites/ui/宗门后山.png" id="2_2atgf"] [ext_resource type="Texture2D" uid="uid://dnjuv1btbxm4o" path="res://assets/sprites/ui/宗门全景.png" id="5_58g0x"] @@ -38,6 +39,10 @@ layout_mode = 3 [node name="BuildingsPanel" parent="." unique_id=795000012 instance=ExtResource("1_b9dpm")] +[node name="SavePanel" parent="." unique_id=794000020 instance=ExtResource("1_s4vpm")] +visible = false + [connection signal="sign_open_buildings" from="TopBarHud" to="." method="_on_top_bar_hud_sign_open_buildings"] [connection signal="sign_open_disciples" from="TopBarHud" to="." method="_on_top_bar_hud_sign_open_disciples"] +[connection signal="sign_open_save" from="TopBarHud" to="." method="_on_top_bar_hud_sign_open_save"] [connection signal="sign_return_mainmenu" from="TopBarHud" to="." method="_on_top_bar_hud_sign_return_mainmenu"] diff --git a/src/Core/SaveSystem.gd b/src/Core/SaveSystem.gd new file mode 100644 index 0000000..7b8499c --- /dev/null +++ b/src/Core/SaveSystem.gd @@ -0,0 +1,286 @@ +## 存档系统(Autoload 单例)。 +## 两级存档:存档组 SaveGroup(一个角色/一次轮回)→ 存档槽 SaveSlot(该组下任意多个进度快照)。 +## 每个新游戏创建一个组;每次存档在该组下新建一个槽;组与槽数量无上限。 +## 文件:user://saves/profile_{组id}/{auto|slot_{时间戳}}.json +extends Node + +## 存档根目录。 +const SAVE_ROOT := "user://saves" +## 存档组列表文件。 +const PROFILES_PATH := "user://saves/profiles.json" +## 自动槽文件名(固定)。 +const AUTO_SLOT := "auto" +## 存档格式版本(兼容迁移预留)。 +const VERSION := 1 + + +## 新建存档组(主菜单"新游戏"调用),返回组 id(名"存档N")。 +func create_profile() -> int: + var profiles := _load_profiles() + var id := 1 + for p in profiles: + id = maxi(id, int(p["id"]) + 1) + profiles.append({ + "id": id, + "name": "存档%d" % id, + "created": Time.get_date_string_from_system(), + "last_date": "", + }) + _save_profiles(profiles) + return id + +## 存档组列表(id/name/created/last_date)。 +func get_profiles() -> Array[Dictionary]: + return _load_profiles() + +## 删除存档组:从列表移除并删除该组目录(含全部槽位)。 +## @param profile_id: 存档组 ID +## @return 是否删除成功 +func delete_profile(profile_id: int) -> bool: + var profiles := _load_profiles() + var removed := false + for i in profiles.size(): + if int(profiles[i]["id"]) == profile_id: + profiles.remove_at(i) + removed = true + break + if not removed: + return false + _save_profiles(profiles) + _remove_dir_recursive("%s/profile_%d" % [SAVE_ROOT, profile_id]) + return true + +## 保存游戏:在指定组下**新建一个槽位**,返回槽文件名;失败返回空串。 +## 槽位命名:组内序号递增(slot_1、slot_2…),删除后从当前最大序号 +1 继续,不重用。 +## @param profile_id: 存档组 ID +func save_game(profile_id: int) -> String: + _ensure_profile_dir(profile_id) + var slot_name := "slot_%d" % _next_slot_index(profile_id) + if not _write_slot(profile_id, slot_name): + return "" + _update_profile_last_date(profile_id) + return slot_name + +## 保存到自动槽(返回主菜单时调用)。 +## @param profile_id: 存档组 ID +func save_auto(profile_id: int) -> bool: + _ensure_profile_dir(profile_id) + return _write_slot(profile_id, AUTO_SLOT) + +## 该组全部槽位(含自动槽,自动槽排最前;手动槽按时间升序)。 +## 返回 [{name, date_text, disciple_count, spirit_stones}]。 +## @param profile_id: 存档组 ID +func get_slots(profile_id: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var dir := DirAccess.open("%s/profile_%d" % [SAVE_ROOT, profile_id]) + if dir == null: + return result + dir.list_dir_begin() + var file_name := dir.get_next() + while file_name != "": + if not dir.current_is_dir() and file_name.ends_with(".json"): + var meta := get_slot_meta(profile_id, file_name.trim_suffix(".json")) + if not meta.is_empty(): + meta["name"] = file_name.trim_suffix(".json") + result.append(meta) + file_name = dir.get_next() + dir.list_dir_end() + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_auto: bool = a["name"] == AUTO_SLOT + var b_auto: bool = b["name"] == AUTO_SLOT + if a_auto != b_auto: + return a_auto + return str(a["name"]) < str(b["name"]) + ) + return result + +## 加载存档:清空当前数据后还原各系统。 +## @param profile_id: 存档组 ID +## @param slot_name: 槽文件名(不含 .json) +## @return 是否加载成功 +func load_game(profile_id: int, slot_name: String) -> bool: + var f := FileAccess.open(_slot_path(profile_id, slot_name), FileAccess.READ) + if f == null: + push_warning("存档不存在: " + _slot_path(profile_id, slot_name)) + return false + var parsed: Variant = JSON.parse_string(f.get_as_text()) + if parsed is not Dictionary: + push_warning("存档解析失败: " + _slot_path(profile_id, slot_name)) + return false + _restore(parsed) + return true + +## 删除槽位文件。 +## @param profile_id: 存档组 ID +## @param slot_name: 槽文件名(不含 .json) +## @return 是否删除成功 +func delete_slot(profile_id: int, slot_name: String) -> bool: + if slot_name == AUTO_SLOT: + return false + var path := _slot_path(profile_id, slot_name) + if not FileAccess.file_exists(path): + return false + return DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK + +## 槽位元数据(列表显示用:日期/弟子数/灵石),空档返回空字典。 +## @param profile_id: 存档组 ID +## @param slot_name: 槽文件名(不含 .json) +func get_slot_meta(profile_id: int, slot_name: String) -> Dictionary: + var f := FileAccess.open(_slot_path(profile_id, slot_name), FileAccess.READ) + if f == null: + return {} + var parsed: Variant = JSON.parse_string(f.get_as_text()) + if parsed is not Dictionary: + return {} + var meta: Dictionary = parsed + var d: Dictionary = meta["saved_date"] + return { + "date_text": "%d年%d月%d日" % [d["year"], d["month"], d["day"]], + "disciple_count": int(meta["disciples"].size()), + "spirit_stones": int(meta["resources"]["spirit_stones"]), + } + +## 清空内存中所有游戏数据(读档/新游戏前调用)。 +func reset() -> void: + DiscipleManager.reset() + BuildingManager.reset() + SectManager.set_spirit_stones(0) + SectManager.set_reputation(0) + SectManager.set_food(0) + +## 收集当前游戏数据并写入槽位文件。 +## @param profile_id: 存档组 ID +## @param slot_name: 槽文件名(不含 .json) +func _write_slot(profile_id: int, slot_name: String) -> bool: + var data := _collect_data(profile_id) + var f := FileAccess.open(_slot_path(profile_id, slot_name), FileAccess.WRITE) + if f == null: + push_warning("存档写入失败: " + _slot_path(profile_id, slot_name)) + return false + f.store_string(JSON.stringify(data, "\t")) + return true + +## 收集当前游戏数据为字典。 +## @param profile_id: 存档组 ID +func _collect_data(profile_id: int) -> Dictionary: + var disciples_data: Array = [] + for d in DiscipleManager.disciples: + disciples_data.append(d.to_dict()) + var buildings_data: Array = [] + for b in BuildingManager.buildings: + buildings_data.append(b.to_dict()) + return { + "version": VERSION, + "profile_id": profile_id, + "saved_date": {"year": GameState.year, "month": GameState.month, "day": GameState.day}, + "date": {"year": GameState.year, "month": GameState.month, "day": GameState.day}, + "resources": { + "spirit_stones": SectManager.spirit_stones, + "reputation": SectManager.reputation, + "food": SectManager.food, + }, + "next_disciple_id": DiscipleManager.get_next_disciple_id(), + "disciples": disciples_data, + "buildings": buildings_data, + } + +## 将存档数据还原到各系统(setter 带信号,HUD 自动刷新)。 +## @param data: 存档字典 +func _restore(data: Dictionary) -> void: + reset() + var d: Dictionary = data["date"] + TimeSystem.set_date(int(d["year"]), int(d["month"]), int(d["day"])) + var r: Dictionary = data["resources"] + SectManager.set_spirit_stones(int(r["spirit_stones"])) + SectManager.set_reputation(int(r["reputation"])) + SectManager.set_food(int(r["food"])) + DiscipleManager.set_next_disciple_id(int(data["next_disciple_id"])) + for item in data["disciples"]: + DiscipleManager.disciples.append(Disciple.from_dict(item)) + DiscipleManager.disciples_changed.emit() + for item in data["buildings"]: + BuildingManager.buildings.append(Building.from_dict(item)) + BuildingManager.buildings_changed.emit() + +## 槽位文件路径。 +func _slot_path(profile_id: int, slot_name: String) -> String: + return "%s/profile_%d/%s.json" % [SAVE_ROOT, profile_id, slot_name] + +## 确保存档组目录存在。 +func _ensure_profile_dir(profile_id: int) -> void: + var da := DirAccess.open("user://") + if da == null: + return + da.make_dir_recursive("saves") + da.make_dir_recursive("saves/profile_%d" % profile_id) + +## 递归删除目录(Godot 无原生递归删除,手动遍历)。 +## @param path: 目录路径(user:// 风格) +func _remove_dir_recursive(path: String) -> void: + var da := DirAccess.open(path) + if da == null: + return + da.list_dir_begin() + var f := da.get_next() + while f != "": + if da.current_is_dir(): + _remove_dir_recursive(path + "/" + f) + else: + DirAccess.remove_absolute(ProjectSettings.globalize_path(path + "/" + f)) + f = da.get_next() + da.list_dir_end() + da.remove(da.get_current_dir()) + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + +## 该组下一个槽位序号(现有最大 slot_N 序号 +1,删除后不重用)。 +## @param profile_id: 存档组 ID +func _next_slot_index(profile_id: int) -> int: + var max_index := 0 + var dir := DirAccess.open("%s/profile_%d" % [SAVE_ROOT, profile_id]) + if dir == null: + return 1 + dir.list_dir_begin() + var f := dir.get_next() + while f != "": + if f.begins_with("slot_") and f.ends_with(".json"): + var idx := int(f.trim_suffix(".json").trim_prefix("slot_")) + max_index = maxi(max_index, idx) + f = dir.get_next() + dir.list_dir_end() + return max_index + 1 + +## 读取存档组列表。 +func _load_profiles() -> Array[Dictionary]: + if not FileAccess.file_exists(PROFILES_PATH): + return [] + var f := FileAccess.open(PROFILES_PATH, FileAccess.READ) + if f == null: + return [] + var parsed: Variant = JSON.parse_string(f.get_as_text()) + if parsed is not Dictionary or not parsed.has("profiles"): + return [] + var result: Array[Dictionary] = [] + for p in parsed["profiles"]: + result.append(p) + return result + +## 写入存档组列表。 +## @param profiles: 存档组列表 +func _save_profiles(profiles: Array[Dictionary]) -> void: + var da := DirAccess.open("user://") + if da != null: + da.make_dir_recursive("saves") + var f := FileAccess.open(PROFILES_PATH, FileAccess.WRITE) + if f == null: + return + f.store_string(JSON.stringify({"profiles": profiles}, "\t")) + +## 更新存档组的最后存档日期。 +## @param profile_id: 存档组 ID +func _update_profile_last_date(profile_id: int) -> void: + var profiles := _load_profiles() + for p in profiles: + if int(p["id"]) == profile_id: + p["last_date"] = "%d年%d月%d日" % [GameState.year, GameState.month, GameState.day] + break + _save_profiles(profiles) diff --git a/src/Core/SaveSystem.gd.uid b/src/Core/SaveSystem.gd.uid new file mode 100644 index 0000000..6dfe3db --- /dev/null +++ b/src/Core/SaveSystem.gd.uid @@ -0,0 +1 @@ +uid://bwt83xydsgcir diff --git a/src/Core/SectManager.gd b/src/Core/SectManager.gd index 1457e04..846d084 100644 --- a/src/Core/SectManager.gd +++ b/src/Core/SectManager.gd @@ -33,3 +33,18 @@ func add_food(amount: int) -> void: func remove_food(amount: int) -> void: food -= amount emit_signal("food_changed", food, -amount) + +func set_spirit_stones(value: int) -> void: + var delta := value - spirit_stones + spirit_stones = value + spirit_stones_changed.emit(spirit_stones, delta) + +func set_reputation(value: int) -> void: + var delta := value - reputation + reputation = value + reputation_changed.emit(reputation, delta) + +func set_food(value: int) -> void: + var delta := value - food + food = value + food_changed.emit(food, delta) diff --git a/src/Sect/buildings/Building.gd b/src/Sect/buildings/Building.gd index 56e00d0..7f39a07 100644 --- a/src/Sect/buildings/Building.gd +++ b/src/Sect/buildings/Building.gd @@ -52,3 +52,15 @@ func get_upgrade_cost() -> int: ## 是否已达等级上限。 func is_max_level() -> bool: return level >= GameConfig.building_max_level + +## 序列化为字典(D8 存档用)。 +func to_dict() -> Dictionary: + return {"type": int(type), "level": level} + +## 从字典还原(D8 读档用)。 +## @param data: to_dict 产生的字典 +static func from_dict(data: Dictionary) -> Building: + var b := Building.new() + b.type = int(data["type"]) as Type + b.level = int(data["level"]) + return b diff --git a/src/UI/HUD/TopBarHUD.tscn b/src/UI/HUD/TopBarHUD.tscn index 32f2d57..08bcd7c 100644 --- a/src/UI/HUD/TopBarHUD.tscn +++ b/src/UI/HUD/TopBarHUD.tscn @@ -91,6 +91,12 @@ size_flags_horizontal = 3 theme = ExtResource("2_ymrsi") text = "资源" +[node name="Button_Save" type="Button" parent="VBoxContainer/HBoxContainer2" unique_id=794000010] +layout_mode = 2 +size_flags_horizontal = 3 +theme = ExtResource("2_ymrsi") +text = "存档" + [node name="Button_Return" type="Button" parent="VBoxContainer/HBoxContainer2" unique_id=867839141] layout_mode = 2 size_flags_horizontal = 3 @@ -225,4 +231,5 @@ size_flags_stretch_ratio = 75.0 [connection signal="button_up" from="VBoxContainer/HBoxContainer2/SpeedGroup/Button_Pause" to="." method="_on_button_pause_button_up"] [connection signal="button_up" from="VBoxContainer/HBoxContainer2/Button_StaffManagement" to="." method="_on_button_staff_management_button_up"] [connection signal="button_up" from="VBoxContainer/HBoxContainer2/Button_ResourcesManagement" to="." method="_on_button_resources_management_button_up"] +[connection signal="button_up" from="VBoxContainer/HBoxContainer2/Button_Save" to="." method="_on_button_save_button_up"] [connection signal="button_up" from="VBoxContainer/HBoxContainer2/Button_Return" to="." method="_on_button_return_button_up"] diff --git a/src/UI/HUD/top_bar_hud.gd b/src/UI/HUD/top_bar_hud.gd index 488a9ea..0c7f0f7 100644 --- a/src/UI/HUD/top_bar_hud.gd +++ b/src/UI/HUD/top_bar_hud.gd @@ -8,6 +8,8 @@ signal sign_return_mainmenu signal sign_open_disciples ## 请求打开建筑面板。 signal sign_open_buildings +## 请求打开存档面板。 +signal sign_open_save @onready var time_label: Label = %Value_Time @onready var currency_label: Label = %Value_Currency @@ -68,6 +70,10 @@ func _on_button_staff_management_button_up() -> void: func _on_button_resources_management_button_up() -> void: sign_open_buildings.emit() +## 点击"存档"按钮:请求打开存档面板(由 MainGame 处理)。 +func _on_button_save_button_up() -> void: + sign_open_save.emit() + ## 点击暂停/继续按钮,切换后刷新按钮状态。 func _on_button_pause_button_up() -> void: TimeSystem.toggle_pause() diff --git a/src/UI/Panels/MainMenu/MainMenu.gd b/src/UI/Panels/MainMenu/MainMenu.gd index ba77ab9..9b4b46f 100644 --- a/src/UI/Panels/MainMenu/MainMenu.gd +++ b/src/UI/Panels/MainMenu/MainMenu.gd @@ -1,15 +1,16 @@ ## 主菜单场景(挂载于 MainMenu.tscn)。 extends Control -## 点击"新游戏":重置 GameState 后进入游戏世界。 +## 点击"新游戏":创建新存档组 → 重置 GameState 后进入游戏世界。 func MainMenu_NewGame_ButtonUp() -> void: - GameState.request_new_game() + var pid := SaveSystem.create_profile() + GameState.request_new_game(pid) get_tree().change_scene_to_file("res://src/Core/MainGame.tscn") -## 点击"读取存档":隐藏主按钮组并显示存档面板。 +## 点击"读取存档":隐藏主按钮组并打开存档面板(读档模式)。 func MainMenu_LoadGame_ButtonUp() -> void: $ButtonGroup.hide() - $SavePanel.show() + $SavePanel.open_load() ## 点击"设置"(TODO:接入设置面板)。 func MainMenu_Settings_ButtonUp() -> void: diff --git a/src/UI/Panels/SavePanel.gd b/src/UI/Panels/SavePanel.gd index 6f6d7ab..06542e3 100644 --- a/src/UI/Panels/SavePanel.gd +++ b/src/UI/Panels/SavePanel.gd @@ -1,10 +1,168 @@ -## 存档选择面板(挂载于 SavePanel.tscn)。 -## 显示存档列表与"新建存档"入口,通过信号通知外部结果。 +## 存档面板(挂载于 SavePanel.tscn)。 +## 两级浏览:左侧存档组列表 → 右侧该组槽位列表。 +## 存档模式(游戏内):点"新建存档"每次创建新槽位;读档模式(主菜单):点槽位读档。 extends PanelContainer +## 面板模式。 +enum Mode { SAVE, LOAD } + ## 请求关闭面板(返回上级界面)。 signal back_pressed -## 点击"返回"按钮。 +## 槽位条目场景。 +const SAVE_SLOT_SCENE := preload("res://src/UI/Panels/SaveSlot.tscn") + +@onready var title_label: Label = %TitleLabel +@onready var group_container: VBoxContainer = %GroupContainer +@onready var slot_list: VBoxContainer = %SlotList +@onready var new_slot_button: Button = %Button_NewSlot + +## 当前模式。 +var mode: Mode = Mode.LOAD +## 当前浏览的存档组 ID。 +var _current_profile: int = 0 +## 打开面板前的暂停状态(存档模式关闭时恢复)。 +var _was_paused := false + + +## 读档模式打开(主菜单,不暂停时间,自动选中第一个存档组)。 +func open_load() -> void: + mode = Mode.LOAD + new_slot_button.visible = false + _current_profile = 0 + var profiles := SaveSystem.get_profiles() + if profiles.size() > 0: + _current_profile = int(profiles[0]["id"]) + _show_profiles() + visible = true + +## 存档模式打开(游戏内,暂停时间,当前组固定高亮)。 +## 点槽位 = 回到该进度点;点"新建存档" = 保存当前进度为新槽。 +func open_save() -> void: + mode = Mode.SAVE + new_slot_button.visible = true + _current_profile = GameState.profile_id + _was_paused = TimeSystem.paused + TimeSystem.set_paused(true) + _show_profiles() + visible = true + +## 关闭面板(恢复打开前的暂停状态)。 +func close_panel() -> void: + visible = false + TimeSystem.set_paused(_was_paused) + +## 点击"返回"。 func _on_button_esc_button_up() -> void: - back_pressed.emit() + close_panel() + if mode == Mode.LOAD: + back_pressed.emit() + +## 点击"新建存档":在所属组下创建新槽位并刷新列表。 +func _on_button_new_slot_button_up() -> void: + if SaveSystem.save_game(_current_profile) != "": + _show_slots() + +## 左列:存档组列表(当前组高亮;存档模式下当前组固定)。 +func _show_profiles() -> void: + title_label.text = "选择存档(存档模式)" if mode == Mode.SAVE else "选择存档" + _clear(group_container) + var profiles := SaveSystem.get_profiles() + if profiles.is_empty(): + var label := Label.new() + label.text = "暂无存档组" + group_container.add_child(label) + _show_slots() + return + for p in profiles: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + var btn := Button.new() + btn.text = "%s %s" % [p["name"], p["last_date"]] + btn.toggle_mode = true + var is_current: bool = int(p["id"]) == _current_profile + btn.set_pressed_no_signal(is_current) + btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + if mode == Mode.SAVE and is_current: + btn.disabled = true + else: + btn.button_up.connect(_on_group_pressed.bind(int(p["id"]))) + row.add_child(btn) + # 删除按钮:存档模式下当前组禁用(正在使用) + var del_btn := Button.new() + del_btn.text = "删" + del_btn.disabled = mode == Mode.SAVE and is_current + del_btn.button_up.connect(_on_profile_delete_requested.bind(int(p["id"]))) + row.add_child(del_btn) + group_container.add_child(row) + _show_slots() + +## 请求删除存档组:直接删除并刷新(当前浏览组删除后自动切到第一组)。 +## @param profile_id: 存档组 ID +func _on_profile_delete_requested(profile_id: int) -> void: + if not SaveSystem.delete_profile(profile_id): + return + if _current_profile == profile_id: + _current_profile = 0 + var profiles := SaveSystem.get_profiles() + if profiles.size() > 0: + _current_profile = int(profiles[0]["id"]) + _show_profiles() + +## 点击存档组:切换浏览。 +## @param profile_id: 存档组 ID +func _on_group_pressed(profile_id: int) -> void: + _current_profile = profile_id + _show_profiles() + +## 右列:当前组槽位列表。 +func _show_slots() -> void: + _clear(slot_list) + if _current_profile == 0: + return + var slots := SaveSystem.get_slots(_current_profile) + if slots.is_empty(): + var label := Label.new() + label.text = "(暂无存档,点上方\"新建存档\")" if mode == Mode.SAVE else "(该组暂无存档)" + slot_list.add_child(label) + return + for i in slots.size(): + _add_slot_entry(i + 1, slots[i]) + +## 添加一条槽位条目。 +## @param index: 序号(1 起) +## @param slot: 槽位元数据 {name, date_text, disciple_count, spirit_stones} +func _add_slot_entry(index: int, slot: Dictionary) -> void: + var entry: PanelContainer = SAVE_SLOT_SCENE.instantiate() + slot_list.add_child(entry) + var slot_name := str(slot["name"]) + var index_text := "自动" if slot_name == "auto" else str(index) + var name_text := "自动存档" if slot_name == "auto" else "存档 %d" % index + entry.setup(slot_name, index_text, name_text, + "%s 弟子%d人" % [slot["date_text"], slot["disciple_count"]], + "灵石 %d" % slot["spirit_stones"]) + entry.slot_pressed.connect(_on_slot_pressed) + entry.delete_requested.connect(_on_slot_delete_requested) + +## 点击槽位:读档模式进入游戏;存档模式回到该进度点(当前未保存进度丢失)。 +## @param slot_name: 槽文件名(不含 .json) +func _on_slot_pressed(slot_name: String) -> void: + if mode == Mode.LOAD: + GameState.request_load_game(_current_profile, slot_name) + get_tree().change_scene_to_file("res://src/Core/MainGame.tscn") + return + # 存档模式:直接加载所选槽位(回到该进度点) + if SaveSystem.load_game(_current_profile, slot_name): + close_panel() + +## 请求删除槽位:直接删除并刷新(确认弹窗后续接入)。 +## @param slot_name: 槽文件名(不含 .json) +func _on_slot_delete_requested(slot_name: String) -> void: + if SaveSystem.delete_slot(_current_profile, slot_name): + _show_slots() + +## 清空容器。 +## @param container: 目标容器 +func _clear(container: VBoxContainer) -> void: + for child in container.get_children(): + child.queue_free() diff --git a/src/UI/Panels/SavePanel.tscn b/src/UI/Panels/SavePanel.tscn index 67eca83..9c4dc37 100644 --- a/src/UI/Panels/SavePanel.tscn +++ b/src/UI/Panels/SavePanel.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://dj7b5vssydifa"] [ext_resource type="Script" uid="uid://boao04d7accm" path="res://src/UI/Panels/SavePanel.gd" id="1_oi6jj"] -[ext_resource type="PackedScene" uid="uid://dk7ggyngkyt18" path="res://src/UI/Panels/SaveSlot.tscn" id="2_uloko"] +[ext_resource type="FontFile" uid="uid://4su41gib4l5u" path="res://assets/fonts/lxgw-wenkai-v1.522/LXGWWenKaiMono-Medium.ttf" id="2_font"] [node name="SavePanel" type="PanelContainer" unique_id=619488443] anchors_preset = 15 @@ -19,16 +19,27 @@ theme_override_constants/separation = 16 layout_mode = 2 [node name="TitleLabel" type="Label" parent="VBoxContainer/Header" unique_id=784512345] +unique_name_in_owner = true layout_mode = 2 -theme_override_font_sizes/font_size = 24 +theme_override_fonts/font = ExtResource("2_font") +theme_override_font_sizes/font_size = 28 text = "选择存档" [node name="Spacer" type="Control" parent="VBoxContainer/Header" unique_id=784512346] layout_mode = 2 size_flags_horizontal = 3 +[node name="Button_NewSlot" type="Button" parent="VBoxContainer/Header" unique_id=794000001] +unique_name_in_owner = true +layout_mode = 2 +theme_override_fonts/font = ExtResource("2_font") +theme_override_font_sizes/font_size = 22 +text = "新建存档" + [node name="Button_Esc" type="Button" parent="VBoxContainer/Header" unique_id=866430190] layout_mode = 2 +theme_override_fonts/font = ExtResource("2_font") +theme_override_font_sizes/font_size = 22 text = "返回" [node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=500577526] @@ -41,21 +52,18 @@ layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 -[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer/HBoxContainer" unique_id=844163531] +[node name="GroupContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer/HBoxContainer" unique_id=844163531] +unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 - -[node name="SaveGroup" type="Button" parent="VBoxContainer/ScrollContainer/HBoxContainer/VBoxContainer" unique_id=150349711] -layout_mode = 2 -text = "存档1" +theme_override_constants/separation = 8 [node name="SlotList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer/HBoxContainer" unique_id=674823582] +unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 size_flags_stretch_ratio = 5.0 +theme_override_constants/separation = 8 -[node name="SaveSlot" parent="VBoxContainer/ScrollContainer/HBoxContainer/SlotList" unique_id=214547620 instance=ExtResource("2_uloko")] -layout_mode = 2 -size_flags_vertical = 1 - +[connection signal="button_up" from="VBoxContainer/Header/Button_NewSlot" to="." method="_on_button_new_slot_button_up"] [connection signal="button_up" from="VBoxContainer/Header/Button_Esc" to="." method="_on_button_esc_button_up"] diff --git a/src/UI/Panels/SaveSlot.gd b/src/UI/Panels/SaveSlot.gd index e66c1bf..f174240 100644 --- a/src/UI/Panels/SaveSlot.gd +++ b/src/UI/Panels/SaveSlot.gd @@ -1,10 +1,13 @@ ## 存档槽位条目(挂载于 SaveSlot.tscn)。 -## 展示单个存档信息;空槽位用于新建存档。 +## 展示单个存档槽位信息,支持点击读档与请求删除。 extends PanelContainer -## 槽位被点击时发出,save_id 为空表示点击了新建槽位。 -## @param save_id: 存档 ID -signal slot_pressed(save_id: String) +## 槽位被点击(读档)时发出。 +## @param slot_name: 槽文件名(不含 .json) +signal slot_pressed(slot_name: String) +## 请求删除该槽位时发出。 +## @param slot_name: 槽文件名(不含 .json) +signal delete_requested(slot_name: String) @onready var slot_index_label: Label = %SlotIndexLabel @onready var save_name_label: Label = %SaveNameLabel @@ -13,40 +16,40 @@ signal slot_pressed(save_id: String) @onready var delete_button: Button = %DeleteButton @onready var arrow_button: Button = %ArrowIcon -## 本槽位对应的存档 ID。 -var save_id: String = "" -## 是否已有存档数据(false 表示新建槽位)。 +## 本槽位文件名(不含 .json)。 +var slot_name: String = "" +## 是否已有存档数据。 var has_data: bool = false ## 填充为已有存档槽位。 -## @param id: 存档 ID -## @param name_text: 存档名称 +## @param name: 槽文件名(不含 .json) +## @param index_text: 序号文本(如 "1"、"自动") +## @param name_text: 槽位显示名(如 "存档 1"、"自动存档") ## @param meta1: 第一行元数据 ## @param meta2: 第二行元数据 -func setup(id: String, name_text: String, meta1: String, meta2: String) -> void: - save_id = id +func setup(name: String, index_text: String, name_text: String, meta1: String, meta2: String) -> void: + slot_name = name has_data = true - slot_index_label.text = id.get_slice("save_", 1) # "save_001" -> "001" + slot_index_label.text = index_text save_name_label.text = name_text meta_line1.text = meta1 meta_line2.text = meta2 delete_button.visible = true arrow_button.visible = true -## 填充为"新建存档"空槽位。 -func setup_empty() -> void: - has_data = false - slot_index_label.text = "+" - save_name_label.text = "新建存档" - meta_line1.text = "点击创建新的存档" - meta_line2.text = "" - delete_button.visible = false - arrow_button.visible = false - -## 槽位本体被点击。 +## 槽位本体被点击(读档/回到进度点)。 func _on_pressed() -> void: - slot_pressed.emit(save_id) + slot_pressed.emit(slot_name) -## 点击删除按钮(TODO:接入确认弹窗)。 +## 点击箭头按钮(读档)。 +func _on_arrow_button_up() -> void: + slot_pressed.emit(slot_name) + +## 根节点点击(左键抬起即触发,覆盖整行)。 +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + slot_pressed.emit(slot_name) + +## 点击删除按钮:发出删除请求(确认由面板处理)。 func _on_delete_button_button_up() -> void: - print("删除存档: ", save_id) + delete_requested.emit(slot_name) diff --git a/src/UI/Panels/SaveSlot.tscn b/src/UI/Panels/SaveSlot.tscn index 77f513c..33899bb 100644 --- a/src/UI/Panels/SaveSlot.tscn +++ b/src/UI/Panels/SaveSlot.tscn @@ -54,4 +54,5 @@ layout_mode = 2 size_flags_horizontal = 3 text = "▶" +[connection signal="button_up" from="HBoxContainer/ArrowIcon" to="." method="_on_arrow_button_up"] [connection signal="button_up" from="HBoxContainer/DeleteButton" to="." method="_on_delete_button_button_up"]