1. 游戏数据管理
游戏数据管理
前面 13 章教你怎么把游戏做出来。但一个完整的游戏,除了画面和操作,还需要管理大量数据——玩家等级、背包物品、设置选项、排行榜分数……这些数据如果丢了,玩家会非常愤怒。
本章讲的就是:游戏里的数据应该怎么存、怎么读、怎么管。
什么是游戏数据管理
打个比方:游戏数据管理就像是开一家超市。你需要知道仓库里有什么货(库存)、顾客买了什么(交易记录)、哪些商品卖得好(统计数据)。如果这些记录丢了,超市就没法正常运转。
游戏也一样。玩家辛苦打了 50 级,关掉游戏再打开发现回到 1 级——这种体验会让人直接卸载游戏。
游戏中有哪些数据
| 数据类型 | 举例 | 丢失后果 |
|---|---|---|
| 存档数据 | 玩家等级、当前关卡、金币数量 | 玩家进度全部丢失 |
| 设置数据 | 音量大小、画面质量、语言选择 | 玩家需要重新设置 |
| 游戏配置 | 物品属性表、怪物血量表 | 游戏数值混乱 |
| 运行时数据 | 当前场景中的敌人位置、临时状态 | 无影响(重新生成即可) |
核心原则
只有存档数据和设置数据需要保存到磁盘。运行时数据存在内存里就够了,关掉游戏就不需要了。
本地存档——最基本的数据持久化
方案一:JSON 文件存档(推荐新手使用)
JSON 是一种文本格式,人类可以直接打开阅读和编辑。用 JSON 存档最简单、最直观。
// 保存游戏数据到 JSON 文件
using Godot;
using System;
using System.IO;
using System.Text.Json;
public partial class SaveManager : Node
{
// 存档文件路径
private const string SavePath = "user://save_data.json";
// 要保存的数据
public class SaveData
{
public int PlayerLevel { get; set; }
public int Gold { get; set; }
public float PlayerX { get; set; }
public float PlayerY { get; set; }
public string CurrentScene { get; set; }
public int[] InventoryItems { get; set; }
}
// 保存
public void SaveGame(SaveData data)
{
try
{
string json = JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true // 美化输出,方便调试
});
File.WriteAllText(ProjectSettings.GlobalizePath(SavePath), json);
GD.Print("存档成功!");
}
catch (Exception e)
{
GD.PrintErr($"存档失败:{e.Message}");
}
}
// 读取
public SaveData LoadGame()
{
string fullPath = ProjectSettings.GlobalizePath(SavePath);
if (!File.Exists(fullPath))
{
GD.Print("没有找到存档文件,返回默认数据");
return CreateDefaultData();
}
try
{
string json = File.ReadAllText(fullPath);
return JsonSerializer.Deserialize<SaveData>(json);
}
catch (Exception e)
{
GD.PrintErr($"读档失败:{e.Message}");
return CreateDefaultData();
}
}
// 创建默认数据(新玩家)
private SaveData CreateDefaultData()
{
return new SaveData
{
PlayerLevel = 1,
Gold = 0,
PlayerX = 0,
PlayerY = 0,
CurrentScene = "res://levels/level_01.tscn",
InventoryItems = Array.Empty<int>()
};
}
}# 保存游戏数据到 JSON 文件
extends Node
const SAVE_PATH = "user://save_data.json"
# 要保存的数据用字典表示
var save_data: Dictionary = {
"player_level": 1,
"gold": 0,
"player_x": 0.0,
"player_y": 0.0,
"current_scene": "res://levels/level_01.tscn",
"inventory_items": []
}
# 保存
func save_game() -> bool:
var json_string = JSON.stringify(save_data, "\t")
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("存档失败:%s" % FileAccess.get_open_error())
return false
file.store_string(json_string)
file.close()
print("存档成功!")
return true
# 读取
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
print("没有找到存档文件,返回默认数据")
return create_default_data()
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("读档失败:%s" % FileAccess.get_open_error())
return create_default_data()
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_string)
if error != OK:
push_error("存档文件格式错误")
return create_default_data()
return json.data
# 创建默认数据(新玩家)
func create_default_data() -> Dictionary:
return {
"player_level": 1,
"gold": 0,
"player_x": 0.0,
"player_y": 0.0,
"current_scene": "res://levels/level_01.tscn",
"inventory_items": []
}方案二:ConfigFile 存档(Godot 内置)
Godot 提供了 ConfigFile 类,专门用来读写 INI 格式的配置文件。适合存设置类数据。
// 使用 ConfigFile 存储游戏设置
using Godot;
public partial class SettingsManager : Node
{
private const string SettingsPath = "user://settings.ini";
public void SaveSettings()
{
var config = new ConfigFile();
config.SetValue("audio", "master_volume", 0.8);
config.SetValue("audio", "music_volume", 0.6);
config.SetValue("audio", "sfx_volume", 1.0);
config.SetValue("display", "fullscreen", false);
config.SetValue("display", "resolution_x", 1920);
config.SetValue("display", "resolution_y", 1080);
config.SetValue("gameplay", "language", "zh-CN");
config.Save(SettingsPath);
}
public void LoadSettings()
{
var config = new ConfigFile();
Error err = config.Load(SettingsPath);
if (err != Error.Ok)
{
GD.Print("设置文件不存在,使用默认值");
return;
}
// 读取设置
double masterVol = (double)config.GetValue("audio", "master_volume", 0.8);
double musicVol = (double)config.GetValue("audio", "music_volume", 0.6);
bool fullscreen = (bool)config.GetValue("display", "fullscreen", false);
// 应用设置
AudioServer.SetBusVolumeDb(
AudioServer.GetBusIndex("Master"),
(float)GD.LinearToDb(masterVol)
);
if (fullscreen)
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
}
}# 使用 ConfigFile 存储游戏设置
extends Node
const SETTINGS_PATH = "user://settings.ini"
func save_settings() -> void:
var config = ConfigFile.new()
config.set_value("audio", "master_volume", 0.8)
config.set_value("audio", "music_volume", 0.6)
config.set_value("audio", "sfx_volume", 1.0)
config.set_value("display", "fullscreen", false)
config.set_value("display", "resolution_x", 1920)
config.set_value("display", "resolution_y", 1080)
config.set_value("gameplay", "language", "zh-CN")
config.save(SETTINGS_PATH)
func load_settings() -> void:
var config = ConfigFile.new()
var err = config.load(SETTINGS_PATH)
if err != OK:
print("设置文件不存在,使用默认值")
return
# 读取设置
var master_vol = config.get_value("audio", "master_volume", 0.8)
var music_vol = config.get_value("audio", "music_volume", 0.6)
var fullscreen = config.get_value("display", "fullscreen", false)
# 应用设置
var master_bus_idx = AudioServer.get_bus_index("Master")
AudioServer.set_bus_volume_db(master_bus_idx, linear_to_db(master_vol))
if fullscreen:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)JSON vs ConfigFile 怎么选
- JSON:适合存复杂数据(列表、嵌套结构),比如背包物品、任务列表
- ConfigFile:适合存简单键值对,比如音量、分辨率、语言设置
两者可以同时使用,各管各的。
方案三:Godot 内置 Resource 序列化
Godot 的 Resource 类天然支持序列化(保存为 .tres 或 .res 文件)。如果你把游戏数据定义为 Resource 的子类,保存和加载会非常方便。
// 自定义 Resource 存储玩家数据
using Godot;
[GlobalClass]
public partial class PlayerData : Resource
{
[Export] public int Level { get; set; } = 1;
[Export] public int MaxHp { get; set; } = 100;
[Export] public int Gold { get; set; } = 0;
[Export] public float[] Position { get; set; } = { 0f, 0f };
}
// 使用 ResourceLoader / ResourceSaver 读写
public partial class ResourceManager : Node
{
private const string DataPath = "user://player_data.tres";
public void SavePlayerData(PlayerData data)
{
Error err = ResourceSaver.Save(data, DataPath);
if (err != Error.Ok)
GD.PrintErr($"保存失败:{err}");
}
public PlayerData LoadPlayerData()
{
if (!ResourceLoader.Exists(DataPath))
return new PlayerData(); // 返回默认数据
return ResourceLoader.Load<PlayerData>(DataPath);
}
}# 自定义 Resource 存储玩家数据
class_name PlayerData
extends Resource
@export var level: int = 1
@export var max_hp: int = 100
@export var gold: int = 0
@export var position: Vector2 = Vector2.ZERO
# 使用 ResourceLoader / ResourceSaver 读写
extends Node
const DATA_PATH = "user://player_data.tres"
func save_player_data(data: PlayerData) -> void:
var err = ResourceSaver.save(data, DATA_PATH)
if err != OK:
push_error("保存失败:%s" % err)
func load_player_data() -> PlayerData:
if not ResourceLoader.exists(DATA_PATH):
return PlayerData.new() # 返回默认数据
return ResourceLoader.load(DATA_PATH)存档安全——防止玩家作弊
存档加密
玩家可以直接打开 JSON 文件,把金币从 100 改成 999999。如果你不希望玩家作弊,可以对存档加密。
// 简单的 XOR 加密存档
using Godot;
using System.Text;
public partial class EncryptedSave : Node
{
private const string SavePath = "user://save.enc";
// 加密密钥(实际项目中应该更复杂,且不要硬编码在代码里)
private static readonly byte[] Key = { 0xAB, 0xCD, 0xEF, 0x12 };
public void SaveEncrypted(string jsonData)
{
byte[] data = Encoding.UTF8.GetBytes(jsonData);
byte[] encrypted = new byte[data.Length];
for (int i = 0; i < data.Length; i++)
{
encrypted[i] = (byte)(data[i] ^ Key[i % Key.Length]);
}
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
file.StoreBuffer(encrypted);
}
public string LoadEncrypted()
{
if (!FileAccess.FileExists(SavePath))
return null;
var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
byte[] encrypted = file.GetBuffer((long)file.GetLength());
byte[] decrypted = new byte[encrypted.Length];
for (int i = 0; i < encrypted.Length; i++)
{
decrypted[i] = (byte)(encrypted[i] ^ Key[i % Key.Length]);
}
return Encoding.UTF8.GetString(decrypted);
}
}# 简单的 XOR 加密存档
extends Node
const SAVE_PATH = "user://save.enc"
# 加密密钥(实际项目中应该更复杂,且不要硬编码在代码里)
const KEY: PackedByteArray = [0xAB, 0xCD, 0xEF, 0x12]
func save_encrypted(json_data: String) -> void:
var data: PackedByteArray = json_data.to_utf8_buffer()
var encrypted: PackedByteArray = PackedByteArray()
for i in range(data.size()):
encrypted.append(data[i] ^ KEY[i % KEY.size()])
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
file.store_buffer(encrypted)
func load_encrypted() -> String:
if not FileAccess.file_exists(SAVE_PATH):
return ""
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
var encrypted: PackedByteArray = file.get_buffer(file.get_length())
var decrypted: PackedByteArray = PackedByteArray()
for i in range(encrypted.size()):
decrypted.append(encrypted[i] ^ KEY[i % KEY.size()])
return decrypted.get_string_from_utf8()加密不是万能的
- 简单的 XOR 加密只能防"君子",专业的作弊者可以反编译游戏找到密钥
- 如果需要真正的安全,应该使用服务端验证(把关键数据存在服务器上)
- 对于单机游戏,适度加密就够了,不要过度纠结
存档校验
除了加密,还可以给存档加上"签名"(checksum),防止玩家篡改后游戏崩溃:
// 计算存档数据的校验和
using Godot;
public partial class SaveValidator : Node
{
// 计算字符串的哈希值
public string CalculateHash(string data)
{
return data.GetHashCode().ToString("X8");
}
// 保存时附加上校验和
public string PackWithHash(string jsonData)
{
string hash = CalculateHash(jsonData);
return $"{hash}|{jsonData}";
}
// 读取时验证校验和
public (bool isValid, string data) UnpackAndVerify(string packed)
{
string[] parts = packed.Split('|', 2);
if (parts.Length != 2)
return (false, "");
string storedHash = parts[0];
string data = parts[1];
string actualHash = CalculateHash(data);
return (storedHash == actualHash, data);
}
}# 计算存档数据的校验和
extends Node
# 计算字符串的哈希值
func calculate_hash(data: String) -> String:
return str(data.hash())
# 保存时附加上校验和
func pack_with_hash(json_data: String) -> String:
var h = calculate_hash(json_data)
return "%s|%s" % [h, json_data]
# 读取时验证校验和
func unpack_and_verify(packed: String) -> Dictionary:
var parts = packed.split("|", false, 1)
if parts.size() != 2:
return {"valid": false, "data": ""}
var stored_hash = parts[0]
var data = parts[1]
var actual_hash = calculate_hash(data)
return {"valid": stored_hash == actual_hash, "data": data}多存档槽位
很多游戏支持多个存档位(比如存档1、存档2、存档3),让玩家可以选择从哪个进度继续。
// 多存档槽位管理
using Godot;
public partial class MultiSlotManager : Node
{
private const int MaxSlots = 3;
public string GetSlotPath(int slotIndex)
{
if (slotIndex < 1 || slotIndex > MaxSlots)
throw new ArgumentOutOfRangeException(nameof(slotIndex));
return $"user://save_slot_{slotIndex}.json";
}
public bool HasSaveData(int slotIndex)
{
return FileAccess.FileExists(GetSlotPath(slotIndex));
}
// 获取所有存档的概要信息(用于显示在读取界面)
public Godot.Collections.Array<Dictionary> GetAllSlotInfo()
{
var info = new Godot.Collections.Array<Dictionary>();
for (int i = 1; i <= MaxSlots; i++)
{
var slotInfo = new Dictionary
{
{ "slot", i },
{ "has_data", HasSaveData(i) }
};
info.Add(slotInfo);
}
return info;
}
public void DeleteSave(int slotIndex)
{
string path = ProjectSettings.GlobalizePath(GetSlotPath(slotIndex));
if (File.Exists(path))
File.Delete(path);
}
}# 多存档槽位管理
extends Node
const MAX_SLOTS = 3
func get_slot_path(slot_index: int) -> String:
if slot_index < 1 or slot_index > MAX_SLOTS:
push_error("无效的存档槽位:%d" % slot_index)
return ""
return "user://save_slot_%d.json" % slot_index
func has_save_data(slot_index: int) -> bool:
return FileAccess.file_exists(get_slot_path(slot_index))
# 获取所有存档的概要信息(用于显示在读取界面)
func get_all_slot_info() -> Array:
var info = []
for i in range(1, MAX_SLOTS + 1):
info.append({
"slot": i,
"has_data": has_save_data(i)
})
return info
func delete_save(slot_index: int) -> void:
var path = get_slot_path(slot_index)
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))自动存档
玩家经常忘记手动存档。好的游戏应该在关键节点自动存档:
| 触发时机 | 说明 |
|---|---|
| 进入新关卡 | 保存当前关卡编号和玩家状态 |
| 通关/失败 | 保存最终结果 |
| 退出游戏 | 玩家关闭游戏时自动保存 |
| 每隔 N 分钟 | 定时存档,防止意外崩溃丢失进度 |
// 自动存档管理器
using Godot;
public partial class AutoSaveManager : Node
{
[Export] public double AutoSaveInterval = 120.0; // 每 2 分钟自动存档
private double _timer;
public override void _Process(double delta)
{
_timer += delta;
if (_timer >= AutoSaveInterval)
{
_timer = 0;
PerformAutoSave();
}
}
private void PerformAutoSave()
{
GD.Print("自动存档中...");
// 调用你的 SaveGame 方法
// GetNode<SaveManager>("SaveManager").SaveGame(currentData);
}
// 游戏退出时保存
public override void _Notification(int what)
{
if (what == NotificationWMCloseRequest)
{
PerformAutoSave();
}
}
}# 自动存档管理器
extends Node
@export var auto_save_interval: float = 120.0 # 每 2 分钟自动存档
var _timer: float = 0.0
func _process(delta):
_timer += delta
if _timer >= auto_save_interval:
_timer = 0.0
perform_auto_save()
func perform_auto_save():
print("自动存档中...")
# 调用你的 save_game 方法
# 游戏退出时保存
func _notification(what):
if what == NOTIFICATION_WM_CLOSE_REQUEST:
perform_auto_save()游戏配置数据
除了玩家存档,游戏本身还有大量配置数据:物品属性、怪物数值、技能效果等。这些数据通常由策划配置,不应该硬编码在代码里。
使用 JSON/CSV 作为数据表
// 从 JSON 数据表加载物品信息
using Godot;
using System.Text.Json;
public partial class ItemDatabase : Node
{
private Dictionary<int, ItemData> _items = new();
public class ItemData
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public int Price { get; set; }
public string Icon { get; set; }
}
public override void _Ready()
{
LoadItems("res://data/items.json");
}
public void LoadItems(string path)
{
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
string json = file.GetAsText();
file.Close();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var itemList = JsonSerializer.Deserialize<List<ItemData>>(json, options);
foreach (var item in itemList)
{
_items[item.Id] = item;
}
GD.Print($"加载了 {_items.Count} 个物品");
}
public ItemData GetItem(int id)
{
return _items.TryGetValue(id, out var item) ? item : null;
}
}# 从 JSON 数据表加载物品信息
extends Node
var items: Dictionary = {} # id -> ItemData
class ItemData:
var id: int
var name: String
var description: String
var price: int
var icon: String
func _ready():
load_items("res://data/items.json")
func load_items(path: String):
var file = FileAccess.open(path, FileAccess.READ)
var json_text = file.get_as_text()
file.close()
var json = JSON.new()
json.parse(json_text)
var item_list = json.data
for item_dict in item_list:
var data = ItemData.new()
data.id = item_dict["id"]
data.name = item_dict["name"]
data.description = item_dict["description"]
data.price = item_dict["price"]
data.icon = item_dict["icon"]
items[data.id] = data
print("加载了 %d 个物品" % items.size())
func get_item(id: int) -> ItemData:
return items.get(id, null)数据表文件示例(items.json)
[
{
"id": 1001,
"name": "生命药水",
"description": "恢复 50 点生命值",
"price": 30,
"icon": "res://assets/items/potion_hp.png"
},
{
"id": 1002,
"name": "铁剑",
"description": "攻击力 +15",
"price": 200,
"icon": "res://assets/items/sword_iron.png"
},
{
"id": 2001,
"name": "火焰符文",
"description": "为武器附加火焰伤害",
"price": 500,
"icon": "res://assets/items/rune_fire.png"
}
]数据管理最佳实践
文件存放规范
| 文件类型 | 存放位置 | 说明 |
|---|---|---|
| 存档数据 | user:// | 用户目录,不同平台路径不同 |
| 设置数据 | user:// | 同上 |
| 游戏配置表 | res://data/ | 项目资源目录,随游戏一起打包 |
user:// 在哪里
Godot 的 user:// 是一个特殊路径,会根据操作系统自动定位:
- Windows:
C:\Users\<用户名>\AppData\Roaming\Godot\app_userdata\<项目名>\ - Android:
/data/data/<包名>/files/ - iOS:
Documents/
使用 user:// 不需要关心具体路径,Godot 会自动处理。
存档版本管理
游戏更新后,存档格式可能变化。建议在存档中加入版本号:
{
"version": 2,
"player_level": 15,
"gold": 3200,
"new_field_added_in_v2": "some value"
}读取时检查版本号,如果是旧版本就进行数据迁移。
下一章
数据管理搞定了,接下来进入更激动人心的部分——做网络游戏。下一章先从最经典的卡牌游戏开始。
