feat: 两级存档系统——无限组/槽,建组建槽/自动档/两级浏览/读档还原/删除组与槽/游戏内加载(D8 完成)
This commit is contained in:
@@ -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] 新游戏流程不受影响(仍弹开局招募);多组多槽共存
|
||||
+2
-2
@@ -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 验收** | 完整跑一遍:新游戏→招弟子→挂机→存→退→读 | 闭环可玩 | ⬜ |
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-8
@@ -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
|
||||
|
||||
+25
-8
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwt83xydsgcir
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
+162
-4
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+29
-26
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user