Godot独立游戏开发:基于SQLite插件构建健壮存档系统
1. 项目概述:为什么独立游戏需要一个“正经”的存档系统?
做独立游戏,尤其是RPG、模拟经营或者带有复杂养成元素的游戏,存档系统往往是开发中后期才会被认真对待的“老大难”问题。很多开发者,包括我自己在早期,都习惯用Godot自带的ConfigFile或者干脆把一堆变量序列化成JSON存到本地文件里。这种做法在原型阶段确实快,但随着游戏内容膨胀,问题就来了:数据难以结构化查询、存档臃肿、版本迁移简直是噩梦、多存档位管理复杂,更别提实现云存档或者跨平台数据同步这种“高级”需求了。
这时候,一个成熟的关系型数据库就显得非常必要。SQLite,这个几乎零配置、单文件、功能却异常强大的嵌入式数据库,就成了独立游戏开发者的绝佳选择。它不需要像MySQL那样搭建服务器,一个.db文件就是你的整个数据库,读写速度快,支持完整的SQL语法,能轻松处理玩家属性、物品栏、任务进度、地图状态等复杂的关系数据。
然而,Godot引擎本身并没有内置对SQLite的直接支持。这就需要我们引入第三方插件。本篇文章,就是带你一步步,告别纯文本和JSON的“草台班子”,用Godot SQLite插件为你的游戏搭建一个健壮、可扩展、易于维护的存档系统。我会从插件选择、环境配置、数据库设计、核心代码实现,一直讲到版本管理和数据迁移这些实战中必然会遇到的坑,并附上完整的、可直接复用的GDScript代码。
2. 核心工具选型与环境搭建
2.1 为什么选择这个SQLite插件?
Godot社区有几个SQLite插件,经过一番对比和实际项目检验,我最终推荐并使用godot-sqlite这个插件。它有几个让我无法拒绝的优点:
- 纯GDScript/NativeScript实现:这意味着它不依赖外部DLL或SO文件,跨平台部署(Windows、macOS、Linux、Android、iOS)极其简单,直接打包就行,没有额外的依赖烦恼。
- API简洁直观:它的API设计非常贴近Godot的风格,用起来就像在使用Godot内置的节点和方法,学习成本低。
- 功能完整:支持参数化查询(防SQL注入)、事务处理、Blob数据类型(存二进制数据如图片),完全能满足游戏存档的需求。
- 活跃维护:在GitHub上更新比较及时,社区反馈和Issue处理也相对积极。
注意:插件的安装方式会随着Godot版本和插件发布形式略有变化。务必去GitHub仓库查看最新的安装说明。通常,你需要下载插件的Release包,将其中的
addons文件夹复制到你的项目根目录,然后在Godot编辑器中的“项目 -> 项目设置 -> 插件”里启用它。
2.2 数据库可视化工具:DB Browser for SQLite
在开发过程中,我们不可能只靠代码来查看和调试数据库。DB Browser for SQLite (DB4S)是一个免费、开源、图形化的SQLite数据库管理工具。强烈建议你安装一个。
它的作用太大了:
- 实时查看数据:游戏运行时,你可以用DB4S打开游戏的存档文件
.db,直接查看表格里的数据是否正确写入,比打Log调试直观一百倍。 - 执行SQL命令:快速测试你的SQL语句,验证查询逻辑。
- 设计和修改表结构:虽然我们提倡用代码来管理表结构(版本迁移),但在原型阶段,用DB4S拖拽修改非常方便。
- 数据导入/导出:方便你将测试数据导入,或者将存档数据导出备份。
安装好DB4S后,你的开发调试流程会顺畅很多。想象一下,当玩家获得一件传奇装备时,你立刻能在DB4S里看到inventory表里多了一条记录,那种掌控感是无与伦比的。
2.3 项目初始化与插件配置
首先,在你的Godot项目中启用godot-sqlite插件。确保你在代码中可以通过preload("res://addons/godot-sqlite/godot-sqlite.gdns")或类似方式引用到SQLite类。
接着,我们需要规划存档文件的存放位置。不能随便放在项目根目录,要考虑不同操作系统的用户数据目录。Godot提供了OS.get_user_data_dir()方法,它会返回一个适合存放用户数据(如存档)的路径。
我通常会在游戏启动的早期,创建一个单例(Autoload)脚本来全局管理数据库连接。这个单例我们姑且称之为DatabaseManager。
# DatabaseManager.gd (作为Autoload单例) extends Node var db: SQLite = null var db_path: String = "" func _ready(): # 确定数据库文件路径 var user_dir = OS.get_user_data_dir() db_path = user_dir.plus_file("save_game.db") # 存档文件名为 save_game.db print("数据库路径: ", db_path) # 初始化数据库连接 init_database() func init_database(): db = SQLite.new() # 第一个参数是数据库文件路径,第二个参数表示如果文件不存在则创建 var error = db.open(db_path, SQLite.READ_WRITE_CREATE) if error != OK: printerr("无法打开数据库!错误码: ", error) return print("数据库连接成功!") # 创建数据表(如果不存在) create_tables()3. 数据库设计与核心表结构解析
设计表结构是存档系统的基石。好的设计逻辑清晰,扩展性强;坏的设计后期改起来痛不欲生。我们以一个典型的RPG游戏为例,设计几个核心表。
3.1 玩家基础信息表 (player)
这张表存放玩家的核心元数据和全局状态。
-- 使用DatabaseManager执行以下SQL CREATE TABLE IF NOT EXISTS player ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- 主键,自增,可用于区分多存档 save_slot INTEGER NOT NULL DEFAULT 0, -- 存档槽位,0,1,2... save_name TEXT, -- 存档名称,如“冒险之旅” created_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 创建时间 last_saved_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 最后保存时间 total_play_time INTEGER DEFAULT 0, -- 总游戏时间(秒) scene_name TEXT, -- 玩家最后所在的场景资源路径 spawn_point TEXT, -- 玩家最后的出生点标识 health INTEGER DEFAULT 100, max_health INTEGER DEFAULT 100, mana INTEGER DEFAULT 50, level INTEGER DEFAULT 1, experience INTEGER DEFAULT 0 );设计思路:
save_slot和id结合,完美支持多存档位。id是数据库内部唯一标识,save_slot是给玩家看的槽位索引。scene_name和spawn_point用于实现“从哪里退出,就从哪里进入”的体验。- 时间戳字段对于实现存档列表的排序和显示非常有用。
- 将基础属性直接放在这里,查询效率高。如果属性非常复杂且动态,可以考虑用JSON存储在一个字段里,或者拆分成另一张属性表。
3.2 物品库存表 (inventory)
这是游戏存档中最活跃的表之一,需要仔细设计。
CREATE TABLE IF NOT EXISTS inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_id INTEGER NOT NULL, -- 关联到 player.id item_id TEXT NOT NULL, -- 物品的唯一标识符,对应你游戏数据表中的ID quantity INTEGER DEFAULT 1, -- 数量 slot_index INTEGER, -- 在背包UI中的位置,可为NULL表示不在背包内(如在仓库) durability INTEGER, -- 耐久度(如果适用) custom_data TEXT, -- 用JSON存储物品的额外属性,如附魔、宝石等 FOREIGN KEY (player_id) REFERENCES player(id) ON DELETE CASCADE );设计思路:
player_id外键关联,确保数据完整性。ON DELETE CASCADE表示如果玩家存档被删除,其所有物品记录也自动删除。item_id是逻辑ID,指向一个静态的游戏物品配置表(这个表可以放在游戏的资源里,不一定要在数据库)。custom_data字段是关键技巧。游戏物品的属性千变万化,一把“火焰之剑”可能有“攻击力+10”、“火焰伤害+5”等属性。将这些动态属性序列化成JSON字符串存储,比为每个可能属性创建一列要灵活得多。读取时再反序列化即可。slot_index允许实现背包的格子管理,null值可以表示物品在仓库或任务物品栏等地方。
3.3 任务进度表 (quests)
记录玩家接受和完成的任务状态。
CREATE TABLE IF NOT EXISTS quests ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_id INTEGER NOT NULL, quest_id TEXT NOT NULL, -- 任务配置ID status INTEGER NOT NULL DEFAULT 0, -- 0=未接受,1=进行中,2=已完成,3=已交付 progress_data TEXT, -- 用JSON存储任务具体进度,如“收集狼牙:5/10” accepted_time DATETIME, completed_time DATETIME, FOREIGN KEY (player_id) REFERENCES player(id) ON DELETE CASCADE );设计思路:
status使用整数枚举,比文本效率高。progress_data同样是JSON字段,用于存储任务目标的具体完成情况。例如一个“收集10个狼牙”的任务,可以存{"wolf_fang": 5}。这样无需为每种任务类型单独设计表结构。
3.4 世界状态表 (world_state)
用于存储那些不属于特定玩家,但属于游戏世界的全局状态,比如NPC是否已被对话、宝箱是否已开启、开关是否已触发等。
CREATE TABLE IF NOT EXISTS world_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_id INTEGER NOT NULL, -- 关联到特定存档 region TEXT NOT NULL, -- 区域标识,如“forest_01” object_id TEXT NOT NULL, -- 游戏内对象唯一ID,如“chest_gold_001” state_key TEXT NOT NULL, -- 状态键,如“is_opened” state_value TEXT, -- 状态值,可以是文本、数字或JSON UNIQUE(player_id, region, object_id, state_key), -- 联合唯一约束,防止重复记录 FOREIGN KEY (player_id) REFERENCES player(id) ON DELETE CASCADE );设计思路:
- 这是一个通用的“键值对”表,但加上了
player_id,region,object_id的维度,可以精确记录游戏中任何一个对象的任意一种状态。 UNIQUE约束确保对同一个对象的同一个状态,只存在一条记录,更新时使用INSERT OR REPLACE语句即可,非常方便。- 这种设计极度灵活,几乎可以记录任何动态的世界交互,是构建沉浸感的关键。
在DatabaseManager的create_tables函数中,我们需要按顺序执行上述所有建表SQL语句。务必使用IF NOT EXISTS,这样无论执行多少次都不会出错。
4. 核心数据操作层实现(附完整代码)
有了表结构,接下来就是实现具体的增删改查(CRUD)操作。我们将这些操作封装在DatabaseManager中,供游戏其他系统调用。
4.1 创建与加载存档
创建新存档:
func create_new_save(save_slot: int, save_name: String) -> int: # 返回新存档的 player.id,失败返回 -1 if db == null: printerr("数据库未初始化!") return -1 var current_time = OS.get_datetime() var time_str = "%04d-%02d-%02d %02d:%02d:%02d" % [current_time.year, current_time.month, current_time.day, current_time.hour, current_time.minute, current_time.second] db.query("BEGIN TRANSACTION;") # 开始事务,确保数据一致性 var error = db.query_with_bindings( "INSERT INTO player (save_slot, save_name, created_time, last_saved_time) VALUES (?, ?, ?, ?);", [save_slot, save_name, time_str, time_str] ) if error != OK: printerr("插入玩家记录失败: ", db.error_message) db.query("ROLLBACK;") return -1 var new_player_id = db.last_insert_rowid # 这里可以初始化该玩家的默认物品、任务等 # initialize_default_inventory(new_player_id) # initialize_starting_quests(new_player_id) db.query("COMMIT;") # 提交事务 print("成功创建存档,ID: ", new_player_id) return new_player_id加载存档列表:
func get_save_slots() -> Array: # 返回所有存档槽位的信息,用于UI显示 var save_slots = [] if db == null: return save_slots db.query("SELECT id, save_slot, save_name, created_time, last_saved_time, total_play_time, level FROM player ORDER BY last_saved_time DESC;") for i in range(db.query_result.size()): var row = db.query_result[i] var slot_info = { "db_id": row["id"], "slot_index": row["save_slot"], "name": row["save_name"], "create_time": row["created_time"], "last_save_time": row["last_saved_time"], "play_time": row["total_play_time"], "player_level": row["level"] } save_slots.append(slot_info) return save_slots4.2 游戏运行时数据的保存与加载
我们需要一个中心化的GameSaveData单例,它在游戏运行时持有当前存档的所有数据。DatabaseManager负责将这个单例的数据与数据库同步。
保存游戏:
# 在DatabaseManager中 func save_game(player_id: int, game_data: Dictionary) -> bool: # game_data 是一个包含所有需要保存数据的字典 # 例如:{ "player": {...}, "inventory": [...], "quests": [...], "world": [...] } if db == null or player_id <= 0: return false db.query("BEGIN TRANSACTION;") # 1. 更新玩家基础信息 var player_data = game_data.get("player", {}) var error = db.query_with_bindings(""" UPDATE player SET scene_name = ?, spawn_point = ?, health = ?, max_health = ?, mana = ?, level = ?, experience = ?, total_play_time = ?, last_saved_time = datetime('now', 'localtime') WHERE id = ?; """, [ player_data.get("scene_name", ""), player_data.get("spawn_point", ""), player_data.get("health", 100), player_data.get("max_health", 100), player_data.get("mana", 50), player_data.get("level", 1), player_data.get("experience", 0), player_data.get("total_play_time", 0), player_id ]) if error != OK: printerr("更新玩家数据失败: ", db.error_message) db.query("ROLLBACK;") return false # 2. 保存物品栏(先删除旧数据,再插入新数据,简化逻辑) db.query_with_bindings("DELETE FROM inventory WHERE player_id = ?;", [player_id]) var inventory = game_data.get("inventory", []) for item in inventory: var custom_data_json = JSON.stringify(item.get("custom_data", {})) error = db.query_with_bindings(""" INSERT INTO inventory (player_id, item_id, quantity, slot_index, durability, custom_data) VALUES (?, ?, ?, ?, ?, ?); """, [player_id, item["item_id"], item.get("quantity", 1), item.get("slot_index"), item.get("durability"), custom_data_json]) if error != OK: printerr("插入物品失败: ", db.error_message) db.query("ROLLBACK;") return false # 3. 保存任务进度(同样采用先删后插) db.query_with_bindings("DELETE FROM quests WHERE player_id = ?;", [player_id]) var quests = game_data.get("quests", []) for quest in quests: var progress_json = JSON.stringify(quest.get("progress_data", {})) error = db.query_with_bindings(""" INSERT INTO quests (player_id, quest_id, status, progress_data, accepted_time, completed_time) VALUES (?, ?, ?, ?, ?, ?); """, [player_id, quest["quest_id"], quest["status"], progress_json, quest.get("accepted_time"), quest.get("completed_time")]) if error != OK: printerr("插入任务失败: ", db.error_message) db.query("ROLLBACK;") return false # 4. 保存世界状态(使用 INSERT OR REPLACE) var world_states = game_data.get("world_state", []) for state in world_states: error = db.query_with_bindings(""" INSERT OR REPLACE INTO world_state (player_id, region, object_id, state_key, state_value) VALUES (?, ?, ?, ?, ?); """, [player_id, state["region"], state["object_id"], state["state_key"], state["state_value"]]) if error != OK: printerr("插入世界状态失败: ", db.error_message) db.query("ROLLBACK;") return false db.query("COMMIT;") print("游戏数据保存成功!玩家ID: ", player_id) return true加载游戏:
func load_game(player_id: int) -> Dictionary: # 返回一个包含所有游戏数据的字典 var game_data = { "player": {}, "inventory": [], "quests": [], "world_state": [] } if db == null or player_id <= 0: return game_data # 1. 加载玩家信息 db.query_with_bindings("SELECT * FROM player WHERE id = ?;", [player_id]) if db.query_result.size() > 0: game_data["player"] = db.query_result[0] # 2. 加载物品栏 db.query_with_bindings("SELECT * FROM inventory WHERE player_id = ? ORDER BY slot_index;", [player_id]) for row in db.query_result: var item = row.duplicate() # 解析JSON格式的custom_data if item.has("custom_data") and item["custom_data"]: var json = JSON.new() var parse_err = json.parse(item["custom_data"]) if parse_err == OK: item["custom_data"] = json.data else: item["custom_data"] = {} game_data["inventory"].append(item) # 3. 加载任务 db.query_with_bindings("SELECT * FROM quests WHERE player_id = ?;", [player_id]) for row in db.query_result: var quest = row.duplicate() if quest.has("progress_data") and quest["progress_data"]: var json = JSON.new() if json.parse(quest["progress_data"]) == OK: quest["progress_data"] = json.data game_data["quests"].append(quest) # 4. 加载世界状态 db.query_with_bindings("SELECT * FROM world_state WHERE player_id = ?;", [player_id]) game_data["world_state"] = db.query_result.duplicate() return game_data4.3 数据查询与业务逻辑封装
除了整体保存加载,游戏运行中经常需要查询特定数据。我们应该封装一些常用的查询方法。
# 检查某个宝箱是否已开启 func is_chest_opened(player_id: int, region: String, chest_id: String) -> bool: db.query_with_bindings( "SELECT state_value FROM world_state WHERE player_id = ? AND region = ? AND object_id = ? AND state_key = 'is_opened';", [player_id, region, chest_id] ) if db.query_result.size() > 0: return db.query_result[0].get("state_value", "false") == "true" return false # 获取玩家背包中所有物品(按格子排序) func get_player_inventory(player_id: int) -> Array: var inventory = [] db.query_with_bindings( "SELECT * FROM inventory WHERE player_id = ? AND slot_index IS NOT NULL ORDER BY slot_index;", [player_id] ) # ... 解析 custom_data ... return inventory # 更新玩家属性(如生命值、经验值) func update_player_stats(player_id: int, stats: Dictionary): # stats 是一个字典,如 {"health": 85, "experience": 150} if stats.empty(): return var set_clauses = [] var values = [] for key in stats.keys(): set_clauses.append("%s = ?" % key) values.append(stats[key]) values.append(player_id) var sql = "UPDATE player SET %s WHERE id = ?;" % ", ".join(set_clauses) db.query_with_bindings(sql, values)5. 存档版本管理与数据迁移实战
这是独立游戏存档系统最容易被忽略,也最容易出大问题的地方。你的游戏发布后,难免要更新版本,增加新功能、新物品、新任务。如果新版本直接读取旧版本的存档,很可能会因为表结构或数据格式不兼容而崩溃。
解决方案:为存档引入版本号。
5.1 设计版本记录表
首先,在数据库中增加一个表,专门用来记录这个存档数据库的版本号。
CREATE TABLE IF NOT EXISTS meta_info ( key TEXT PRIMARY KEY, value TEXT ); -- 初始化版本号 INSERT OR REPLACE INTO meta_info (key, value) VALUES ('schema_version', '1');5.2 实现版本升级逻辑
在DatabaseManager初始化时,检查当前数据库版本,并执行必要的升级脚本。
func check_and_upgrade_schema(): var current_version = 1 # 尝试获取当前版本 db.query("SELECT value FROM meta_info WHERE key = 'schema_version';") if db.query_result.size() > 0: current_version = int(db.query_result[0]["value"]) else: # 表不存在,是最初的版本,创建meta_info表并设置版本为1 db.query("CREATE TABLE IF NOT EXISTS meta_info (key TEXT PRIMARY KEY, value TEXT);") db.query_with_bindings("INSERT INTO meta_info (key, value) VALUES ('schema_version', ?);", [str(current_version)]) var target_version = 2 # 这是你游戏代码期望的数据库版本 while current_version < target_version: current_version += 1 _upgrade_to_version(current_version) # 更新版本号 db.query_with_bindings("UPDATE meta_info SET value = ? WHERE key = 'schema_version';", [str(target_version)]) func _upgrade_to_version(version: int): match version: 2: print("正在升级数据库到版本 2...") # 示例:为player表添加一个‘金币’字段 db.query("ALTER TABLE player ADD COLUMN gold INTEGER DEFAULT 0;") # 示例:为inventory表添加一个‘绑定状态’字段 db.query("ALTER TABLE inventory ADD COLUMN bound_to_player BOOLEAN DEFAULT 0;") print("版本 2 升级完成。") 3: print("正在升级数据库到版本 3...") # 更复杂的升级,例如拆分表,数据迁移等 # db.query("CREATE TABLE new_table ...") # db.query("INSERT INTO new_table SELECT ... FROM old_table ...") # db.query("DROP TABLE old_table ...") print("版本 3 升级完成。") _: printerr("未知的数据库版本升级目标: ", version)关键要点:
- 幂等性:每个升级脚本必须可以安全地重复执行。使用
ALTER TABLE ... ADD COLUMN IF NOT EXISTS或者先检查列是否存在再添加。 - 数据迁移:如果修改了表结构(如重命名列、修改数据类型),需要编写数据迁移脚本,将旧数据转换到新格式。
- 测试!测试!测试!:务必用真实的旧版本存档测试升级流程。可以在发布新版本前,备份玩家的存档目录进行测试。
6. 常见问题、性能优化与避坑指南
6.1 常见问题与排查
问题1:数据库文件被锁定,无法写入(特别是在Windows上)。
- 原因:数据库连接没有正确关闭。可能是游戏崩溃,或者多个线程/场景同时尝试连接同一个数据库文件。
- 解决:
- 确保
DatabaseManager是单例,全局只有一个连接。 - 在游戏退出时(
_notification(NOTIFICATION_WM_CLOSE_REQUEST)),显式调用db.close()。 - 如果使用多线程,SQLite本身支持多线程读,但写操作需要序列化。建议将所有数据库操作放在主线程,或者使用互斥锁(Mutex)严格保护。
- 确保
问题2:存档/加载速度慢,游戏卡顿。
- 原因:每次操作都单独执行一条SQL,并且没有使用事务,导致磁盘I/O频繁。
- 解决:
- 使用事务:如上面代码所示,在批量插入、更新、删除操作(如保存整个物品栏)时,务必用
BEGIN TRANSACTION;和COMMIT;包裹起来。这能将多次磁盘写入合并为一次,极大提升速度。 - 批量操作:尽可能合并操作。比如保存100件物品,用一条带多个值的
INSERT语句(如果插件支持)或在事务中循环插入,远比执行100条独立的INSERT语句快。 - 建立索引:对于经常用于查询条件的列(如
player_id,item_id,quest_id),创建索引可以大幅加速查询。CREATE INDEX idx_inventory_player ON inventory(player_id); CREATE INDEX idx_quests_player_status ON quests(player_id, status);
- 使用事务:如上面代码所示,在批量插入、更新、删除操作(如保存整个物品栏)时,务必用
问题3:SQL注入风险。
- 原因:使用字符串拼接来构造SQL语句。
- 解决:永远不要拼接SQL字符串!坚持使用插件提供的参数化查询方法(如
query_with_bindings)。如上文所有示例所示,用?或:name作为占位符,将变量值通过数组或字典绑定进去。
问题4:存档文件损坏。
- 原因:游戏崩溃或断电时,数据库可能处于“中间状态”。
- 解决:
- SQLite本身具有抗崩溃性,但为了更安全,可以定期(如每次保存时)调用
db.backup(“backup.db”)(如果插件支持)进行备份。 - 在加载存档时,可以尝试执行一条简单的查询(如
SELECT 1;),如果失败,则提示玩家存档损坏,并尝试加载备份。
- SQLite本身具有抗崩溃性,但为了更安全,可以定期(如每次保存时)调用
6.2 性能优化技巧
- 按需加载:不要一次性把整个存档的所有数据(比如全地图所有物体的状态)都加载到内存。只在玩家进入某个区域时,加载该区域的
world_state。 - 缓存热点数据:将玩家最常访问的数据(如基础属性、当前装备)缓存在
GameSaveData单例中,避免频繁查询数据库。 - 异步保存:对于自动保存功能,可以考虑将保存操作放到一个单独的线程(需谨慎处理线程安全)或使用
call_deferred在帧间隙处理,避免主线程卡顿。 - 定期清理:如果游戏有“日志”或“历史记录”类的表,可以设置一个上限,定期清理旧数据,防止数据库文件无限膨胀。
6.3 一个完整的存档/加载流程示例
最后,我们串一下在游戏中的典型调用流程:
# 在游戏主菜单场景 func _on_load_button_pressed(slot_index: int): var save_slots = DatabaseManager.get_save_slots() var target_save = null for save in save_slots: if save.slot_index == slot_index: target_save = save break if target_save: # 1. 从数据库加载数据 var loaded_data = DatabaseManager.load_game(target_save.db_id) # 2. 将数据注入到游戏运行时单例 GameSaveData.load_from_dict(loaded_data) # 3. 切换场景到玩家最后所在的位置 var target_scene = load(GameSaveData.player.scene_name) get_tree().change_scene_to_packed(target_scene) # 4. 在目标场景中,根据 spawn_point 设置玩家位置 # ... # 在游戏过程中,手动保存 func _on_save_button_pressed(): # 1. 从游戏运行时单例收集所有需要保存的数据 var data_to_save = GameSaveData.get_save_dictionary() # 2. 调用DatabaseManager保存 var success = DatabaseManager.save_game(GameSaveData.current_player_id, data_to_save) if success: show_message("游戏已保存!") else: show_message("保存失败!") # 自动保存(例如,进入安全屋、完成任务时) func auto_save(): # 可以加一个轻微的延迟,避免在关键操作(如战斗)中保存 call_deferred("_deferred_auto_save") func _deferred_auto_save(): var data_to_save = GameSaveData.get_save_dictionary() DatabaseManager.save_game(GameSaveData.current_player_id, data_to_save) print("自动保存完成。")这套基于Godot SQLite插件的存档系统,从设计到实现,涵盖了独立游戏开发中存档需求的大部分场景。它结构清晰、易于扩展、性能可靠,并且通过版本管理机制具备了面向未来的能力。将你的游戏数据从脆弱的文本文件中解放出来,交给专业的数据库管理,是项目迈向成熟和稳定的重要一步。