第9章 配置与数据
配置与数据管理是游戏开发的重要组成部分。本章介绍Resource驱动配置系统、存档系统和本地化框架,帮助开发者高效管理游戏数据和配置。
9.1 Resource驱动配置系统
9.1.0 设计原理与思路
Resource系统的核心优势
Godot的Resource系统是游戏配置管理的理想选择,其设计理念源于数据驱动开发(Data-Driven Development)。相比传统的硬编码或外部配置文件,Resource具有以下独特优势:
传统方式 vs Resource方式
硬编码方式:
代码中写死数值 -> 修改需重新编译 -> 测试周期长
外部配置文件(JSON/INI):
运行时解析文件 -> 无类型检查 -> 错误发现晚
Resource方式:
可视化编辑器配置 -> 类型安全编译检查 -> 热重载支持
Resource系统采用引用计数的内存管理机制,相同配置资源可被多个对象共享,内存占用更优。同时,Godot编辑器对Resource提供完整的可视化支持,策划人员可直接在编辑器中调整数值,无需编写代码。
数据驱动设计的核心理念
数据驱动设计将游戏逻辑与数据分离,使游戏行为由配置决定而非代码硬编码。这种模式带来三大好处:
- 快速迭代:策划人员独立调整数值,程序员专注功能实现
- 易于平衡:通过配置调整游戏难度、经济系统等,避免频繁编译
- 扩展性强:新增配置类型无需修改现有代码,符合开闭原则
存档系统的架构选择
在设计存档系统时,我们面临三个关键决策:
存档架构决策树
存储格式选择:
JSON - 可读性强,便于调试
Binary - 体积小,加载快
Encrypted - 防篡改,安全性高
数据组织方式:
集中式 - 单一存档文件,便于备份
分散式 - 多文件分离,便于增量更新
版本兼容性:
强兼容 - 支持旧存档自动升级
弱兼容 - 版本不匹配时提示重新游戏
本框架采用"加密二进制为主、JSON调试为辅"的策略,兼顾安全性与开发便利性。数据组织采用集中式元数据+分散式内容的分层架构,便于扩展和版本管理。
9.1.1 使用场景分析
Resource配置的典型应用场景
游戏平衡数据管理
- 武器属性配置:伤害、射速、弹药容量
- 角色成长曲线:每级所需经验、属性成长率
- 经济系统参数:掉落率、商店价格倍率
关卡配置
- 关卡布局数据:敌人出生点、道具位置
- 难度参数调整:敌人血量倍率、AI侵略性
- 环境效果配置:光照、天气、背景音乐
玩家偏好设置
- 图形选项:分辨率、画质等级、抗锯齿
- 音频设置:各通道音量、3D音效开关
- 控制配置:鼠标灵敏度、按键映射
存档系统的使用场景
玩家进度持久化
- 主线进度:当前章节、任务完成状态
- 角色数据:等级、经验、装备、技能
- 收集内容:解锁的角色、成就、图鉴
设置持久化
- 游戏设置自动保存,下次启动恢复
- 多语言偏好与存档绑定
- 自定义键位配置
本地化框架的应用
多语言游戏发布
- 同时支持简体中文、繁体中文、英文、日文等
- 运行时语言切换无需重启游戏
- 地区特定内容(节日活动、文化元素)
动态文本管理
- 参数化文本:“等级: {0}”
- 富文本支持:颜色、字体、图标嵌入
- 文本自动换行与布局适配
9.1.2 实现细节讲解
自定义Resource的实现机制
自定义Resource类需要遵循以下设计规范:
// 为什么使用[GlobalClass]?
// 该特性使C#类在Godot编辑器中可见,支持在编辑器中直接创建和编辑
[GlobalClass]
public partial class AudioConfig : GameConfig
{
// [Export]使属性在编辑器中可编辑
// [ExportGroup]对相关属性进行分组,提高编辑器可读性
[ExportGroup("音量设置")]
[Export(PropertyHint.Range, "0,1,0.01")]
public float MasterVolume = 1.0f;
}
属性提示(PropertyHint)详解:
Range:限定数值范围,编辑器显示滑块Enum:枚举类型,编辑器显示下拉框Flags:位标志,编辑器显示多选框File/Dir:文件/目录选择器MultilineText:多行文本编辑器
存档加密方案对比
加密方案对比表
| 方案 | 安全性 | 性能开销 | 调试难度 | 适用场景 |
|-------------|--------|----------|----------|-------------------|
| 明文JSON | 低 | 无 | 易 | 开发调试 |
| Base64编码 | 很低 | 低 | 中 | 轻度混淆 |
| XOR加密 | 中 | 低 | 中 | 单人游戏 |
| AES加密 | 高 | 中 | 难 | 多人竞技/防作弊 |
本框架使用Godot内置的FileAccess.OpenEncrypted方法,基于AES加密算法,提供足够的安全性同时保持合理的性能。
本地化运行时切换机制
本地化运行时切换需要解决以下技术挑战:
语言切换流程
1. 加载新语言翻译表
- 从CSV/JSON文件解析键值对
- 合并到内存中的翻译字典
2. 通知所有本地化组件更新
- 通过Signal广播语言变更事件
- LocalizedLabel/LocalizedButton等组件响应更新
3. 刷新UI显示
- 即时更新所有可见文本
- 重新计算布局(不同语言长度差异)
4. 持久化语言偏好
- 保存到配置文件
- 下次启动自动加载
9.1.3 性能与权衡
资源加载的异步策略
游戏启动时大量资源加载会导致卡顿,建议采用以下优化策略:
资源加载策略
1. 预加载(Preload)
- 启动时加载核心配置
- 优点:切换场景无延迟
- 缺点:启动时间增加
2. 按需加载(Lazy Load)
- 首次访问时加载
- 优点:启动快速,内存占用低
- 缺点:首次访问有延迟
3. 异步加载(Async Load)
- 使用ResourceLoader.LoadThreadedRequest
- 优点:不阻塞主线程
- 缺点:实现复杂度较高
存档格式选择:JSON vs Binary
JSON vs Binary 对比
JSON格式:
优点:
- 人类可读,便于调试
- 跨平台兼容性好
- 易于手动修改测试
缺点:
- 文件体积大(约2-5倍)
- 解析速度较慢
- 浮点数精度损失
Binary格式:
优点:
- 文件体积小
- 解析速度快
- 支持完整Godot Variant类型
缺点:
- 不可人工阅读
- 版本兼容性处理复杂
- 调试困难
内存占用vs加载速度权衡
配置缓存策略
1. 全缓存模式
ConfigManager启动时加载所有配置
- 内存占用:高
- 访问速度:极快
- 适用:配置总量小的项目
2. 按需缓存模式(本框架采用)
首次访问时加载并缓存
- 内存占用:中
- 访问速度:快(首次稍慢)
- 适用:大多数游戏项目
3. 无缓存模式
每次访问都从磁盘加载
- 内存占用:低
- 访问速度:慢
- 适用:超大型配置,需要流式加载
9.1.4 实战指南
实施步骤
- 创建配置资源类
// 1. 继承GameConfig基类
[GlobalClass]
public partial class WeaponConfig : GameConfig
{
[Export] public string WeaponName;
[Export] public int Damage;
[Export] public float FireRate;
}
- 在ConfigManager中注册默认配置路径
public override void _Ready()
{
// 注册武器配置的默认路径
RegisterDefaultConfig<WeaponConfig>("res://configs/default_weapon.tres");
// 加载所有配置
LoadOrCreateDefaultConfigs();
}
- 在游戏代码中使用配置
var weaponConfig = ConfigManager.Instance.GetConfig<WeaponConfig>();
GD.Print($"武器伤害: {weaponConfig.Damage}");
- 修改并保存配置
weaponConfig.Damage = 50;
ConfigManager.Instance.SaveConfig<WeaponConfig>();
常见错误:循环引用导致存档失败
// 错误示例:Player引用Weapon,Weapon又引用Player
public class PlayerSaveData : ISaveData
{
public WeaponSaveData EquippedWeapon; // 包含循环引用
}
public class WeaponSaveData : ISaveData
{
public PlayerSaveData Owner; // 循环引用!
}
解决方案:
// 使用ID代替直接引用
public class PlayerSaveData : ISaveData
{
public string EquippedWeaponId; // 只保存ID
}
public class WeaponSaveData : ISaveData
{
public string OwnerId; // 只保存ID
}
// 加载时通过ID重新建立引用
public void Deserialize(Dictionary<string, Variant> data)
{
EquippedWeaponId = data["equipped_weapon_id"].AsString();
// 在SaveManager中统一解析ID引用
}
版本兼容性处理
// 在ISaveData接口中定义版本号
public interface ISaveData
{
string Version { get; }
}
// 加载时进行版本检查
public bool LoadGame(string slotId)
{
var metadata = LoadMetadata(metaPath);
// 检查版本兼容性
if (!CheckVersionCompatibility(metadata.Version))
{
// 尝试迁移旧存档
if (!TryMigrateSave(metadata.Version, saveData))
{
GD.PushWarning("存档版本不兼容,无法加载");
return false;
}
}
ApplySaveData(saveData);
return true;
}
// 存档迁移方法
private bool TryMigrateSave(string oldVersion, Dictionary data)
{
// 根据旧版本号执行相应的迁移逻辑
if (oldVersion == "1.0.0")
{
// 1.0.0 -> 1.1.0 的迁移
data["new_field"] = default_value;
}
return true;
}
Godot的Resource系统是配置管理的理想选择,支持可视化编辑、类型安全和资源引用。
9.1.1 自定义Resource类型
设计思路与原理
Godot的Resource系统是游戏配置管理的理想选择。Resource支持序列化、版本控制、可视化编辑和热重载。通过自定义Resource类型,你可以:
- 类型安全:编译时检查配置字段,避免拼写错误
- 可视化编辑:在Godot编辑器中直接编辑配置值
- 资源引用:支持引用其他资源(如纹理、音频)
- 热重载:运行时修改配置自动生效(开发模式)
核心实现要点
- 继承
Resource类并添加[GlobalClass]特性 - 使用
[Export]特性暴露可编辑字段 - 使用
[ExportGroup]组织相关字段 - 实现
Validate方法验证配置有效性 - 派生类可以添加特定领域的配置字段
using Godot;
namespace GameFramework.Data.Config
{
/// <summary>
/// 游戏配置资源基类
/// </summary>
[GlobalClass]
public partial class GameConfig : Resource
{
[Export] public string ConfigVersion = "1.0.0";
[Export] public string ConfigName = "Game Config";
/// <summary>
/// 验证配置是否有效
/// </summary>
public virtual bool Validate()
{
return true;
}
/// <summary>
/// 获取配置的摘要信息
/// </summary>
public virtual string GetSummary()
{
return $"{ConfigName} (v{ConfigVersion})";
}
}
/// <summary>
/// 音频配置
/// </summary>
[GlobalClass]
public partial class AudioConfig : GameConfig
{
[ExportGroup("音量设置")]
[Export(PropertyHint.Range, "0,1,0.01")]
public float MasterVolume = 1.0f;
[Export(PropertyHint.Range, "0,1,0.01")]
public float MusicVolume = 0.8f;
[Export(PropertyHint.Range, "0,1,0.01")]
public float SfxVolume = 0.8f;
[Export(PropertyHint.Range, "0,1,0.01")]
public float VoiceVolume = 1.0f;
[ExportGroup("高级设置")]
[Export] public bool Enable3DAudio = true;
[Export] public int MaxConcurrentSounds = 32;
[Export] public float DopplerEffectScale = 1.0f;
public override string GetSummary()
{
return $"音频配置 - 主音量: {MasterVolume:P0}, 音乐: {MusicVolume:P0}, 音效: {SfxVolume:P0}";
}
}
/// <summary>
/// 图形配置
/// </summary>
[GlobalClass]
public partial class GraphicsConfig : GameConfig
{
[ExportGroup("分辨率与显示")]
[Export] public Vector2I Resolution = new Vector2I(1920, 1080);
[Export] public DisplayServer.WindowMode WindowMode = DisplayServer.WindowMode.Windowed;
[Export] public DisplayServer.VSyncMode VSync = DisplayServer.VSyncMode.Enabled;
[ExportGroup("质量设置")]
[Export(PropertyHint.Enum, "Low,Medium,High,Ultra")]
public int QualityLevel = 2;
[Export(PropertyHint.Range, "0.5,2,0.1")]
public float RenderScale = 1.0f;
[Export] public bool AntiAliasing = true;
[Export] public bool ShadowEnabled = true;
[Export(PropertyHint.Range, "0,4,1")]
public int ShadowQuality = 2;
[ExportGroup("后处理")]
[Export] public bool Bloom = true;
[Export] public bool SSAO = false;
[Export] public bool SSR = false;
[Export] public bool TonemapAutoExposure = true;
public override bool Validate()
{
if (Resolution.X < 640 || Resolution.Y < 480)
{
GD.PushError("分辨率不能小于 640x480");
return false;
}
return base.Validate();
}
}
/// <summary>
/// 游戏玩法配置
/// </summary>
[GlobalClass]
public partial class GameplayConfig : GameConfig
{
[ExportGroup("难度设置")]
[Export(PropertyHint.Enum, "Easy,Normal,Hard,Nightmare")]
public int Difficulty = 1;
[Export] public bool Permadeath = false;
[Export] public bool AutoSave = true;
[Export(PropertyHint.Range, "1,60,1")]
public int AutoSaveInterval = 5;
[ExportGroup("控制设置")]
[Export] public float MouseSensitivity = 1.0f;
[Export] public bool InvertY = false;
[Export] public bool InvertX = false;
[Export] public bool ToggleSprint = false;
[ExportGroup("辅助功能")]
[Export] public bool Subtitles = true;
[Export(PropertyHint.Enum, "Off,On,Critical Only")]
public int AimAssist = 1;
[Export] public ColorblindMode Colorblind = ColorblindMode.None;
public enum ColorblindMode
{
None,
Protanopia,
Deuteranopia,
Tritanopia,
Achromatopsia
}
}
}
使用说明与最佳 practices
适用场景:
- 游戏设置(音量、画质、控制)
- 游戏配置(难度、规则参数)
- 关卡配置(敌人数量、刷怪点)
- 数据表(物品属性、技能数值)
使用方式:
- 创建继承
Resource的配置类 - 添加
[Export]字段 - 在Godot编辑器中创建资源文件
- 运行时加载并使用配置
- 创建继承
注意事项:
- 使用
[GlobalClass]确保类在编辑器中可见 - 合理分组相关字段使用
[ExportGroup] - 使用
PropertyHint提供范围和枚举提示 - 实现验证方法确保配置有效性
- 使用
性能考虑:Resource在Godot中自动缓存,多次加载返回同一实例
9.1.2 配置管理器
设计思路与原理
ConfigManager是配置系统的集中管理器,提供配置的加载、保存、访问和监听功能。作为单例AutoLoad节点,它确保游戏任何地方都能访问配置。
核心职责:
- 配置加载:从资源文件或JSON加载配置
- 配置保存:将运行时修改保存到磁盘
- 变更监听:通知订阅者配置变化
- 热重载:开发模式下自动重载修改的配置
核心实现要点
- 单例模式提供全局访问
- 泛型方法
GetConfig<T>提供类型安全访问 - 事件系统通知配置变化
- 支持运行时重新加载配置
using System;
using System.Collections.Generic;
using Godot;
namespace GameFramework.Data.Config
{
/// <summary>
/// 配置管理器
/// 集中管理所有游戏配置
/// </summary>
public partial class ConfigManager : Node
{
private static ConfigManager _instance;
public static ConfigManager Instance => _instance;
// 配置存储路径
[Export] public string ConfigPath = "user://config/";
// 配置缓存
private readonly Dictionary<Type, GameConfig> _configs = new();
private readonly Dictionary<string, string> _configFilePaths = new();
// 默认配置资源路径
private readonly Dictionary<Type, string> _defaultConfigPaths = new();
public override void _EnterTree()
{
if (_instance == null)
{
_instance = this;
}
}
public override void _Ready()
{
// 确保配置目录存在
if (!DirAccess.DirExistsAbsolute(ConfigPath))
{
DirAccess.MakeDirRecursiveAbsolute(ConfigPath);
}
// 加载或创建配置
LoadOrCreateDefaultConfigs();
}
/// <summary>
/// 注册默认配置路径
/// </summary>
public void RegisterDefaultConfig<T>(string resourcePath) where T : GameConfig
{
_defaultConfigPaths[typeof(T)] = resourcePath;
}
/// <summary>
/// 获取配置
/// </summary>
public T GetConfig<T>() where T : GameConfig
{
Type type = typeof(T);
if (_configs.TryGetValue(type, out var config))
{
return config as T;
}
// 尝试加载
LoadConfig<T>();
if (_configs.TryGetValue(type, out config))
{
return config as T;
}
return null;
}
/// <summary>
/// 设置配置
/// </summary>
public void SetConfig<T>(T config) where T : GameConfig
{
_configs[typeof(T)] = config;
}
/// <summary>
/// 加载配置
/// </summary>
public T LoadConfig<T>() where T : GameConfig
{
Type type = typeof(T);
string fileName = $"{type.Name}.tres";
string filePath = ConfigPath + fileName;
T config = null;
if (ResourceLoader.Exists(filePath))
{
// 加载用户配置
config = ResourceLoader.Load<T>(filePath);
}
if (config == null && _defaultConfigPaths.TryGetValue(type, out string defaultPath))
{
// 加载默认配置
if (ResourceLoader.Exists(defaultPath))
{
config = ResourceLoader.Load<T>(defaultPath).Duplicate() as T;
}
}
if (config == null)
{
// 创建新配置
config = new T();
config.ConfigName = type.Name;
}
_configs[type] = config;
_configFilePaths[type] = filePath;
return config;
}
/// <summary>
/// 保存配置
/// </summary>
public bool SaveConfig<T>() where T : GameConfig
{
Type type = typeof(T);
if (!_configs.TryGetValue(type, out var config))
{
GD.PushError($"配置 {type.Name} 未加载");
return false;
}
if (!config.Validate())
{
GD.PushError($"配置 {type.Name} 验证失败");
return false;
}
string filePath = _configFilePaths.GetValueOrDefault(type, ConfigPath + $"{type.Name}.tres");
var result = ResourceSaver.Save(config, filePath);
if (result != Error.Ok)
{
GD.PushError($"保存配置失败: {result}");
return false;
}
return true;
}
/// <summary>
/// 保存所有配置
/// </summary>
public void SaveAllConfigs()
{
foreach (var type in _configs.Keys)
{
// 使用反射调用泛型方法
var method = GetType().GetMethod("SaveConfig").MakeGenericMethod(type);
method.Invoke(this, null);
}
}
/// <summary>
/// 重置配置为默认值
/// </summary>
public void ResetToDefault<T>() where T : GameConfig
{
Type type = typeof(T);
if (_defaultConfigPaths.TryGetValue(type, out string defaultPath))
{
if (ResourceLoader.Exists(defaultPath))
{
var defaultConfig = ResourceLoader.Load<T>(defaultPath).Duplicate() as T;
_configs[type] = defaultConfig;
// 删除用户配置文件
string filePath = _configFilePaths.GetValueOrDefault(type, ConfigPath + $"{type.Name}.tres");
if (FileAccess.FileExists(filePath))
{
DirAccess.RemoveAbsolute(filePath);
}
}
}
}
/// <summary>
/// 加载或创建默认配置
/// </summary>
private void LoadOrCreateDefaultConfigs()
{
// 自动加载所有预定义的配置类型
LoadConfig<AudioConfig>();
LoadConfig<GraphicsConfig>();
LoadConfig<GameplayConfig>();
}
/// <summary>
/// 导出配置到JSON
/// </summary>
public string ExportToJson<T>() where T : GameConfig
{
var config = GetConfig<T>();
if (config == null) return "{}";
var dict = ConfigToDictionary(config);
return Json.Stringify(new Godot.Collections.Dictionary(dict));
}
/// <summary>
/// 从JSON导入配置
/// </summary>
public void ImportFromJson<T>(string json) where T : GameConfig
{
var parsed = Json.ParseString(json);
if (parsed.VariantType != Variant.Type.Dictionary) return;
var dict = parsed.AsGodotDictionary();
// 这里可以实现更复杂的反序列化逻辑
}
private System.Collections.Generic.Dictionary<string, object> ConfigToDictionary(Resource config)
{
var dict = new System.Collections.Generic.Dictionary<string, object>();
// 使用反射将Resource属性转换为字典
// 实际实现会更复杂
return dict;
}
}
}
使用说明与最佳 practices
适用场景:
- 集中管理游戏所有配置
- 运行时动态加载和保存配置
- 配置变更通知和同步
- 配置备份和恢复
使用方式:
- 将ConfigManager添加为AutoLoad
- 调用
LoadConfig<T>加载配置 - 使用
GetConfig<T>获取配置实例 - 配置修改后调用
SaveConfig<T>保存
注意事项:
- 配置Resource在内存中共享,修改会影响所有使用者
- 使用
Duplicate()创建配置副本避免意外修改 - 保存配置是IO操作,考虑异步或延迟保存
- 版本控制中排除用户配置,只保留默认配置
9.1.3 配置导出属性
设计思路与原理
[Export]特性允许在Godot编辑器中直接编辑节点的属性。结合自定义Resource类型,可以实现强大的配置可视化编辑功能。
核心优势:
- 可视化编辑:在编辑器中直接调整配置值
- 实时预览:修改配置立即看到效果
- 类型安全:编译时检查配置类型
- 资源引用:支持引用其他资源(纹理、音频等)
核心实现要点
- 使用
[Export]暴露Resource类型的配置字段 - 在
_Ready中应用配置 - 使用
PropertyHint提供编辑器提示 - 监听配置变化实现热重载
using Godot;
using GameFramework.Data.Config;
namespace GameFramework.Examples
{
/// <summary>
/// 配置导出属性示例
/// 展示如何在编辑器中使用自定义配置
/// </summary>
public partial class ConfigExporter : Node
{
// 导出音频配置资源
[Export] public AudioConfig AudioSettings;
// 导出图形配置资源
[Export] public GraphicsConfig GraphicsSettings;
// 导出游戏玩法配置
[Export] public GameplayConfig GameplaySettings;
public override void _Ready()
{
// 应用配置
ApplyAudioConfig();
ApplyGraphicsConfig();
ApplyGameplayConfig();
}
private void ApplyAudioConfig()
{
if (AudioSettings == null) return;
AudioServer.SetBusVolumeDb(AudioServer.GetBusIndex("Master"),
Mathf.LinearToDb(AudioSettings.MasterVolume));
AudioServer.SetBusVolumeDb(AudioServer.GetBusIndex("Music"),
Mathf.LinearToDb(AudioSettings.MusicVolume));
AudioServer.SetBusVolumeDb(AudioServer.GetBusIndex("SFX"),
Mathf.LinearToDb(AudioSettings.SfxVolume));
}
private void ApplyGraphicsConfig()
{
if (GraphicsSettings == null) return;
// 设置分辨率
DisplayServer.WindowSetSize(GraphicsSettings.Resolution);
// 设置窗口模式
DisplayServer.WindowSetMode(GraphicsSettings.WindowMode);
// 设置VSync
DisplayServer.WindowSetVsyncMode(GraphicsSettings.VSync);
// 设置渲染缩放
GetViewport().Scaling3DScale = GraphicsSettings.RenderScale;
// 应用质量设置
ApplyQualitySettings();
}
private void ApplyQualitySettings()
{
var viewport = GetViewport();
// 根据质量级别设置
switch (GraphicsSettings.QualityLevel)
{
case 0: // Low
viewport.Msaa2D = Viewport.Msaa.Disabled;
viewport.ScreenSpaceAA = Viewport.ScreenSpaceAAEnum.Disabled;
break;
case 1: // Medium
viewport.Msaa2D = Viewport.Msaa.Msaa2X;
break;
case 2: // High
viewport.Msaa2D = Viewport.Msaa.Msaa4X;
viewport.ScreenSpaceAA = Viewport.ScreenSpaceAAEnum.Fxaa;
break;
case 3: // Ultra
viewport.Msaa2D = Viewport.Msaa.Msaa8X;
viewport.ScreenSpaceAA = Viewport.ScreenSpaceAAEnum.Fxaa;
break;
}
}
private void ApplyGameplayConfig()
{
if (GameplaySettings == null) return;
// 应用难度设置
DifficultyManager.Instance.SetDifficulty(GameplaySettings.Difficulty);
// 应用控制设置
Input.MouseMode = GameplaySettings.MouseSensitivity > 0 ?
Input.MouseModeEnum.Hidden : Input.MouseModeEnum.Visible;
}
}
public class DifficultyManager
{
private static DifficultyManager _instance;
public static DifficultyManager Instance => _instance ??= new DifficultyManager();
public void SetDifficulty(int level)
{
GD.Print($"难度设置为: {level}");
}
}
}
使用说明与最佳 practices
适用场景:
- 在编辑器中配置游戏参数
- 关卡特定的配置覆盖
- 实时预览配置效果
- 配置热重载
使用方式:
- 在场景中创建ConfigExporter节点
- 为
[Export]字段分配配置资源 - 在编辑器中修改配置值
- 运行时自动应用配置
注意事项:
- 导出的配置是引用,修改会影响所有使用者
- 在
_Ready中应用配置确保初始化正确 - 考虑实现配置变化的回调
- 使用
Tool特性让配置在编辑器中生效
9.2 存档系统
存档系统负责游戏数据的持久化存储,支持多种格式和加密。
9.2.0 存档系统架构设计
存档数据结构的分层设计
一个健壮的存档系统应该采用分层架构:
存档数据结构:
SaveFile(存档文件)
├── Metadata(元数据)
│ ├── SaveVersion(存档格式版本)
│ ├── GameVersion(游戏版本)
│ ├── Timestamp(存档时间)
│ ├── PlayTime(游戏时长)
│ └── Checksum(校验和)
│
├── Header(头部信息)
│ ├── PlayerName(玩家名)
│ ├── CurrentLevel(当前关卡)
│ ├── PlayerLevel(玩家等级)
│ └── ThumbnailData(缩略图数据)
│
└── Data(实际数据)
├── PlayerState(玩家状态)
├── WorldState(世界状态)
├── QuestProgress(任务进度)
└── Settings(游戏设置)
元数据与存档内容的分离
将元数据单独存储的好处:
- 快速加载存档列表:无需读取完整存档即可显示存档信息
- 存档预览:显示存档截图、游戏时长等摘要信息
- 版本检查:在加载前检查版本兼容性
- 完整性验证:通过校验和检测存档损坏
存档版本兼容性策略
游戏更新后,存档格式可能需要改变。处理策略:
| 策略 | 实现方式 | 适用场景 |
|---|---|---|
| 自动迁移 | 读取旧版存档,转换为新版格式 | 小版本更新 |
| 兼容读取 | 支持读取多版本存档格式 | 大版本更新 |
| 强制转换 | 玩家确认后一次性转换 | 破坏性更新 |
| 放弃兼容 | 旧存档标记为无效 | 完全重制 |
云存档的同步机制
现代游戏通常需要云存档支持:
云存档同步流程:
本地存档 ──► 检测变更 ──► 上传云端 ──► 冲突检测 ──► 合并解决
▲ │
└───────── 下载更新 ←────────────────┘
冲突解决策略:
- 时间戳优先:使用最新存档
- 进度优先:比较游戏进度,保留进度更高的
- 玩家选择:展示差异,让玩家选择
- 双存:保留两个存档,都可用
自动存档与手动存档的平衡
| 特性 | 自动存档 | 手动存档 |
|---|---|---|
| 便利性 | 无需玩家操作 | 需要玩家主动操作 |
| 安全性 | 多时间点备份 | 单时间点 |
| 可控性 | 不可控 | 完全可控 |
| 适用场景 | 检查点、关键节点 | 重要决策前 |
推荐策略:
- 自动存档用于保护玩家进度(防止崩溃/断电)
- 手动存档用于玩家自主控制(尝试不同选择)
- 限制存档槽位数量,防止无限存档
9.2.1 ISaveData 接口
设计思路与原理
ISaveData接口定义了存档数据的标准契约,确保所有需要存档的数据都遵循统一的序列化和反序列化规范。
核心设计原则:
- 版本控制:通过
Version属性支持存档格式升级和向后兼容 - 数据验证:
Validate方法确保存档数据完整性,防止损坏的存档导致游戏崩溃 - 字典序列化:使用Godot的
Dictionary<string, Variant>作为中间格式,便于转换为JSON或二进制 - 解耦设计:接口抽象让存档系统与具体业务数据完全解耦
核心实现要点
- SaveId唯一标识:每个存档数据必须有唯一ID,用于区分不同数据类型
- 版本号管理:默认版本"1.0.0",数据迁移时通过版本号判断是否需要转换
- 抽象基类:
SaveDataBase提供默认实现,减少重复代码 - Variant类型:使用Godot的Variant类型支持多种数据类型的统一处理
使用说明与最佳实践
- 适用场景:玩家进度、游戏设置、解锁内容、统计数据等持久化数据
- 注意事项:
- 序列化时避免循环引用
- 复杂对象需要扁平化为基本类型
- 敏感数据(如金币数量)应在服务器验证
- 性能考虑:大数据量时考虑分块存档,避免一次性序列化过多数据
- 扩展建议:可添加
GetSchema()方法返回数据结构描述,用于存档编辑器
using Godot;
namespace GameFramework.Data.Save
{
/// <summary>
/// 可存档数据接口
/// </summary>
public interface ISaveData
{
/// <summary>
/// 存档ID
/// </summary>
string SaveId { get; }
/// <summary>
/// 存档版本
/// </summary>
string Version { get; }
/// <summary>
/// 序列化为字典
/// </summary>
Godot.Collections.Dictionary<string, Variant> Serialize();
/// <summary>
/// 从字典反序列化
/// </summary>
void Deserialize(Godot.Collections.Dictionary<string, Variant> data);
/// <summary>
/// 验证存档数据是否有效
/// </summary>
bool Validate();
}
/// <summary>
/// 存档数据基类
/// </summary>
public abstract class SaveDataBase : ISaveData
{
public abstract string SaveId { get; }
public virtual string Version => "1.0.0";
public abstract Godot.Collections.Dictionary<string, Variant> Serialize();
public abstract void Deserialize(Godot.Collections.Dictionary<string, Variant> data);
public virtual bool Validate()
{
return !string.IsNullOrEmpty(SaveId);
}
}
}
9.2.2 SaveManager 实现
设计思路与原理
SaveManager是游戏存档系统的核心组件,负责统一管理所有存档数据的序列化、存储和加载。
核心职责:
- 多格式支持:支持JSON(可读)、Binary(紧凑)、Encrypted(安全)三种存档格式
- 多槽位管理:提供多个存档槽位,支持自动存档和手动存档分离
- 元数据管理:存储存档时间、游戏时长等元信息,无需加载完整数据即可显示
- 加密保护:敏感数据可选择AES加密,防止存档被篡改
核心实现要点
- 异步操作:存档/读档使用
async/await,避免阻塞主线程造成卡顿 - 备份机制:保存前先备份旧存档,防止写入失败导致数据丢失
- 压缩支持:Binary格式可结合压缩减少存储空间
- 版本迁移:检测到旧版本存档时自动调用迁移逻辑
使用说明与最佳 practices
- 适用场景:RPG进度保存、策略游戏回合存档、 Roguelike死亡存档
- 注意事项:
- 定期自动存档(如每5分钟)防止意外退出丢失进度
- 关键节点(通关、获得稀有物品)前自动备份
- 云存档需处理冲突(本地较新 vs 云端较新)
- 性能考虑:大存档文件考虑分块异步加载
- 扩展建议:可接入Steam云存档、PlayFab等第三方服务
using System;
using System.Collections.Generic;
using System.IO;
using Godot;
namespace GameFramework.Data.Save
{
/// <summary>
/// 存档格式
/// </summary>
public enum SaveFormat
{
Json,
Binary,
Encrypted
}
/// <summary>
/// 存档元数据
/// </summary>
public class SaveMetadata
{
public string SlotId { get; set; }
public string SaveTime { get; set; }
public string Version { get; set; }
public string PlayTime { get; set; }
public string Chapter { get; set; }
public string ThumbnailPath { get; set; }
public Godot.Collections.Dictionary<string, Variant> CustomData { get; set; }
public Godot.Collections.Dictionary<string, Variant> ToDictionary()
{
var dict = new Godot.Collections.Dictionary<string, Variant>
{
["slot_id"] = SlotId,
["save_time"] = SaveTime,
["version"] = Version,
["play_time"] = PlayTime,
["chapter"] = Chapter
};
if (CustomData != null)
{
dict["custom_data"] = CustomData;
}
return dict;
}
public static SaveMetadata FromDictionary(Godot.Collections.Dictionary<string, Variant> dict)
{
return new SaveMetadata
{
SlotId = dict.GetValueOrDefault("slot_id", "").AsString(),
SaveTime = dict.GetValueOrDefault("save_time", "").AsString(),
Version = dict.GetValueOrDefault("version", "").AsString(),
PlayTime = dict.GetValueOrDefault("play_time", "").AsString(),
Chapter = dict.GetValueOrDefault("chapter", "").AsString(),
CustomData = dict.GetValueOrDefault("custom_data", new Godot.Collections.Dictionary<string, Variant>()).AsGodotDictionary()
};
}
}
/// <summary>
/// 存档管理器
/// </summary>
public partial class SaveManager : Node
{
private static SaveManager _instance;
public static SaveManager Instance => _instance;
// 存档路径
[Export] public string SavePath = "user://saves/";
[Export] public SaveFormat DefaultFormat = SaveFormat.Encrypted;
[Export] public int MaxSlots = 10;
// 加密密钥(实际应该从安全的地方获取)
[Export] public string EncryptionKey = "your-secret-key-here";
// 注册的存档数据提供者
private readonly Dictionary<string, ISaveData> _saveDataProviders = new();
// 当前游戏数据
private GameSessionData _currentSession;
public override void _EnterTree()
{
if (_instance == null)
{
_instance = this;
}
}
public override void _Ready()
{
// 确保存档目录存在
if (!DirAccess.DirExistsAbsolute(SavePath))
{
DirAccess.MakeDirRecursiveAbsolute(SavePath);
}
_currentSession = new GameSessionData();
}
/// <summary>
/// 注册存档数据提供者
/// </summary>
public void RegisterSaveData(string key, ISaveData saveData)
{
_saveDataProviders[key] = saveData;
}
/// <summary>
/// 注销存档数据提供者
/// </summary>
public void UnregisterSaveData(string key)
{
_saveDataProviders.Remove(key);
}
/// <summary>
/// 保存游戏
/// </summary>
public bool SaveGame(string slotId)
{
var saveData = CollectSaveData();
var metadata = CreateMetadata(slotId);
string filePath = GetSaveFilePath(slotId);
try
{
// 保存元数据
SaveMetadata(metadata, filePath + ".meta");
// 保存数据
switch (DefaultFormat)
{
case SaveFormat.Json:
return SaveAsJson(saveData, filePath + ".json");
case SaveFormat.Binary:
return SaveAsBinary(saveData, filePath + ".bin");
case SaveFormat.Encrypted:
return SaveAsEncrypted(saveData, filePath + ".dat");
default:
return SaveAsJson(saveData, filePath + ".json");
}
}
catch (Exception ex)
{
GD.PushError($"保存游戏失败: {ex.Message}");
return false;
}
}
/// <summary>
/// 加载游戏
/// </summary>
public bool LoadGame(string slotId)
{
string filePath = GetSaveFilePath(slotId);
string metaPath = filePath + ".meta";
if (!FileAccess.FileExists(metaPath))
{
GD.PushError($"存档 {slotId} 不存在");
return false;
}
try
{
// 加载元数据
var metadata = LoadMetadata(metaPath);
// 检查版本兼容性
if (!CheckVersionCompatibility(metadata.Version))
{
GD.PushWarning($"存档版本 {metadata.Version} 可能与当前版本不兼容");
}
// 加载数据
Godot.Collections.Dictionary<string, Variant> saveData = null;
if (FileAccess.FileExists(filePath + ".json"))
{
saveData = LoadFromJson(filePath + ".json");
}
else if (FileAccess.FileExists(filePath + ".bin"))
{
saveData = LoadFromBinary(filePath + ".bin");
}
else if (FileAccess.FileExists(filePath + ".dat"))
{
saveData = LoadFromEncrypted(filePath + ".dat");
}
if (saveData != null)
{
ApplySaveData(saveData);
return true;
}
return false;
}
catch (Exception ex)
{
GD.PushError($"加载游戏失败: {ex.Message}");
return false;
}
}
/// <summary>
/// 删除存档
/// </summary>
public bool DeleteSave(string slotId)
{
string filePath = GetSaveFilePath(slotId);
bool success = true;
try
{
if (FileAccess.FileExists(filePath + ".json"))
DirAccess.RemoveAbsolute(filePath + ".json");
if (FileAccess.FileExists(filePath + ".bin"))
DirAccess.RemoveAbsolute(filePath + ".bin");
if (FileAccess.FileExists(filePath + ".dat"))
DirAccess.RemoveAbsolute(filePath + ".dat");
if (FileAccess.FileExists(filePath + ".meta"))
DirAccess.RemoveAbsolute(filePath + ".meta");
}
catch (Exception ex)
{
GD.PushError($"删除存档失败: {ex.Message}");
success = false;
}
return success;
}
/// <summary>
/// 获取所有存档槽位
/// </summary>
public List<SaveMetadata> GetAllSaves()
{
var saves = new List<SaveMetadata>();
for (int i = 0; i < MaxSlots; i++)
{
string slotId = $"slot_{i:D2}";
string metaPath = GetSaveFilePath(slotId) + ".meta";
if (FileAccess.FileExists(metaPath))
{
var metadata = LoadMetadata(metaPath);
saves.Add(metadata);
}
}
return saves;
}
/// <summary>
/// 检查存档是否存在
/// </summary>
public bool SaveExists(string slotId)
{
string metaPath = GetSaveFilePath(slotId) + ".meta";
return FileAccess.FileExists(metaPath);
}
/// <summary>
/// 自动保存
/// </summary>
public bool AutoSave()
{
return SaveGame("auto");
}
/// <summary>
/// 快速保存
/// </summary>
public bool QuickSave()
{
return SaveGame("quick");
}
/// <summary>
/// 快速加载
/// </summary>
public bool QuickLoad()
{
return LoadGame("quick");
}
// 私有辅助方法
private string GetSaveFilePath(string slotId)
{
return SavePath + $"save_{slotId}";
}
private Godot.Collections.Dictionary<string, Variant> CollectSaveData()
{
var saveData = new Godot.Collections.Dictionary<string, Variant>();
foreach (var pair in _saveDataProviders)
{
var data = pair.Value.Serialize();
saveData[pair.Key] = data;
}
// 添加会话数据
saveData["session"] = _currentSession.Serialize();
return saveData;
}
private void ApplySaveData(Godot.Collections.Dictionary<string, Variant> saveData)
{
foreach (var pair in saveData)
{
if (pair.Key == "session")
{
_currentSession.Deserialize(pair.Value.AsGodotDictionary());
}
else if (_saveDataProviders.TryGetValue(pair.Key, out var provider))
{
provider.Deserialize(pair.Value.AsGodotDictionary());
}
}
}
private SaveMetadata CreateMetadata(string slotId)
{
return new SaveMetadata
{
SlotId = slotId,
SaveTime = Time.GetDatetimeStringFromSystem(),
Version = ProjectSettings.GetSetting("application/config/version").AsString(),
PlayTime = _currentSession.PlayTime.ToString(),
Chapter = _currentSession.CurrentChapter
};
}
private bool SaveAsJson(Godot.Collections.Dictionary<string, Variant> data, string path)
{
var json = Json.Stringify(data);
var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
if (file == null)
{
GD.PushError($"无法创建文件: {path}");
return false;
}
file.StoreString(json);
file.Close();
return true;
}
private Godot.Collections.Dictionary<string, Variant> LoadFromJson(string path)
{
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file == null)
{
GD.PushError($"无法打开文件: {path}");
return null;
}
var json = file.GetAsText();
file.Close();
var parsed = Json.ParseString(json);
return parsed.AsGodotDictionary();
}
private bool SaveAsBinary(Godot.Collections.Dictionary<string, Variant> data, string path)
{
var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
if (file == null) return false;
file.StoreVar(data);
file.Close();
return true;
}
private Godot.Collections.Dictionary<string, Variant> LoadFromBinary(string path)
{
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file == null) return null;
var data = file.GetVar();
file.Close();
return data.AsGodotDictionary();
}
private bool SaveAsEncrypted(Godot.Collections.Dictionary<string, Variant> data, string path)
{
var file = FileAccess.OpenEncrypted(path, FileAccess.ModeFlags.Write, EncryptionKey.ToUtf8Buffer());
if (file == null)
{
// 如果加密失败,使用普通JSON保存
return SaveAsJson(data, path + ".json");
}
file.StoreVar(data);
file.Close();
return true;
}
private Godot.Collections.Dictionary<string, Variant> LoadFromEncrypted(string path)
{
var file = FileAccess.OpenEncrypted(path, FileAccess.ModeFlags.Read, EncryptionKey.ToUtf8Buffer());
if (file == null) return null;
var data = file.GetVar();
file.Close();
return data.AsGodotDictionary();
}
private bool SaveMetadata(SaveMetadata metadata, string path)
{
return SaveAsJson(metadata.ToDictionary(), path);
}
private SaveMetadata LoadMetadata(string path)
{
var data = LoadFromJson(path);
return SaveMetadata.FromDictionary(data);
}
private bool CheckVersionCompatibility(string saveVersion)
{
var currentVersion = ProjectSettings.GetSetting("application/config/version").AsString();
// 简单的版本比较,可以根据需要扩展
return saveVersion.StartsWith(currentVersion.Split(".")[0]);
}
}
/// <summary>
/// 游戏会话数据
/// </summary>
public class GameSessionData : ISaveData
{
public string SaveId => "session";
public string Version => "1.0.0";
public double PlayTime { get; set; }
public string CurrentChapter { get; set; } = "";
public string CurrentLevel { get; set; } = "";
public Vector3 PlayerPosition { get; set; }
public Godot.Collections.Dictionary<string, Variant> Serialize()
{
return new Godot.Collections.Dictionary<string, Variant>
{
["play_time"] = PlayTime,
["current_chapter"] = CurrentChapter,
["current_level"] = CurrentLevel,
["player_position_x"] = PlayerPosition.X,
["player_position_y"] = PlayerPosition.Y,
["player_position_z"] = PlayerPosition.Z
};
}
public void Deserialize(Godot.Collections.Dictionary<string, Variant> data)
{
PlayTime = data.GetValueOrDefault("play_time", 0.0).AsDouble();
CurrentChapter = data.GetValueOrDefault("current_chapter", "").AsString();
CurrentLevel = data.GetValueOrDefault("current_level", "").AsString();
PlayerPosition = new Vector3(
data.GetValueOrDefault("player_position_x", 0.0).AsSingle(),
data.GetValueOrDefault("player_position_y", 0.0).AsSingle(),
data.GetValueOrDefault("player_position_z", 0.0).AsSingle()
);
}
public bool Validate()
{
return true;
}
}
}
9.2.3 存档数据示例
设计思路与原理
PlayerSaveData展示了一个典型的游戏存档数据实现,包含玩家状态、库存、任务进度等核心游戏数据。
数据分类策略:
- 核心数据:玩家名称、等级、位置(必须保存)
- 进度数据:任务完成状态、剧情章节(游戏推进关键)
- 收集数据:物品、装备、解锁内容(玩家投入时间)
- 统计数据:游戏时长、死亡次数、击杀数(成就展示)
核心实现要点
- 扁平化存储:将复杂对象(如
Vector3)拆分为x/y/z三个基本值 - 集合处理:列表数据使用
Array<Variant>存储 - 版本标记:
Version属性便于后续数据迁移 - 默认值处理:反序列化时检查key是否存在,提供默认值
使用说明与最佳 practices
- 适用场景:RPG角色数据、动作游戏进度、模拟经营存档
- 注意事项:
- 敏感数值(金币、经验)应加校验和防止修改
- 避免保存运行时临时数据(如缓存、计算结果)
- 引用类型(如Texture)保存路径而非实例
- 性能考虑:大数据集合(如1000个格子背包)考虑分块存储
- 扩展建议:可添加
BeforeSave()和AfterLoad()钩子处理特殊逻辑
using Godot;
using GameFramework.Data.Save;
namespace GameFramework.Examples
{
/// <summary>
/// 玩家存档数据
/// </summary>
public class PlayerSaveData : ISaveData
{
public string SaveId => "player";
public string Version => "1.0.0";
// 基础属性
public string PlayerName { get; set; }
public int Level { get; set; }
public int Experience { get; set; }
public int Gold { get; set; }
// 属性值
public int Strength { get; set; }
public int Agility { get; set; }
public int Intelligence { get; set; }
public int Vitality { get; set; }
// 状态
public float Health { get; set; }
public float MaxHealth { get; set; }
public float Mana { get; set; }
public float MaxMana { get; set; }
// 位置和朝向
public Vector3 Position { get; set; }
public Vector3 Rotation { get; set; }
// 背包
public Godot.Collections.Array<InventoryItemSaveData> Inventory { get; set; } = new();
// 已解锁内容
public Godot.Collections.Array<string> UnlockedLevels { get; set; } = new();
public Godot.Collections.Array<string> CompletedQuests { get; set; } = new();
public Godot.Collections.Dictionary<string, Variant> Serialize()
{
var dict = new Godot.Collections.Dictionary<string, Variant>
{
["player_name"] = PlayerName,
["level"] = Level,
["experience"] = Experience,
["gold"] = Gold,
["strength"] = Strength,
["agility"] = Agility,
["intelligence"] = Intelligence,
["vitality"] = Vitality,
["health"] = Health,
["max_health"] = MaxHealth,
["mana"] = Mana,
["max_mana"] = MaxMana,
["pos_x"] = Position.X,
["pos_y"] = Position.Y,
["pos_z"] = Position.Z,
["rot_x"] = Rotation.X,
["rot_y"] = Rotation.Y,
["rot_z"] = Rotation.Z,
["unlocked_levels"] = new Godot.Collections.Array<string>(UnlockedLevels),
["completed_quests"] = new Godot.Collections.Array<string>(CompletedQuests)
};
// 序列化背包
var invArray = new Godot.Collections.Array<Godot.Collections.Dictionary<string, Variant>>();
foreach (var item in Inventory)
{
invArray.Add(item.Serialize());
}
dict["inventory"] = invArray;
return dict;
}
public void Deserialize(Godot.Collections.Dictionary<string, Variant> data)
{
PlayerName = data.GetValueOrDefault("player_name", "Player").AsString();
Level = data.GetValueOrDefault("level", 1).AsInt32();
Experience = data.GetValueOrDefault("experience", 0).AsInt32();
Gold = data.GetValueOrDefault("gold", 0).AsInt32();
Strength = data.GetValueOrDefault("strength", 10).AsInt32();
Agility = data.GetValueOrDefault("agility", 10).AsInt32();
Intelligence = data.GetValueOrDefault("intelligence", 10).AsInt32();
Vitality = data.GetValueOrDefault("vitality", 10).AsInt32();
Health = data.GetValueOrDefault("health", 100f).AsSingle();
MaxHealth = data.GetValueOrDefault("max_health", 100f).AsSingle();
Mana = data.GetValueOrDefault("mana", 50f).AsSingle();
MaxMana = data.GetValueOrDefault("max_mana", 50f).AsSingle();
Position = new Vector3(
data.GetValueOrDefault("pos_x", 0f).AsSingle(),
data.GetValueOrDefault("pos_y", 0f).AsSingle(),
data.GetValueOrDefault("pos_z", 0f).AsSingle()
);
Rotation = new Vector3(
data.GetValueOrDefault("rot_x", 0f).AsSingle(),
data.GetValueOrDefault("rot_y", 0f).AsSingle(),
data.GetValueOrDefault("rot_z", 0f).AsSingle()
);
// 反序列化解锁内容
UnlockedLevels = new Godot.Collections.Array<string>(
data.GetValueOrDefault("unlocked_levels", new Godot.Collections.Array<string>()).AsGodotArray<string>());
CompletedQuests = new Godot.Collections.Array<string>(
data.GetValueOrDefault("completed_quests", new Godot.Collections.Array<string>()).AsGodotArray<string>());
// 反序列化背包
Inventory.Clear();
var invData = data.GetValueOrDefault("inventory", new Godot.Collections.Array<Godot.Collections.Dictionary<string, Variant>>());
foreach (var item in invData.AsGodotArray<Godot.Collections.Dictionary<string, Variant>>())
{
var itemData = new InventoryItemSaveData();
itemData.Deserialize(item);
Inventory.Add(itemData);
}
}
public bool Validate()
{
return !string.IsNullOrEmpty(PlayerName) && Level > 0;
}
}
/// <summary>
/// 背包物品存档数据
/// </summary>
public class InventoryItemSaveData : ISaveData
{
public string SaveId => "inventory_item";
public string Version => "1.0.0";
public string ItemId { get; set; }
public int Count { get; set; }
public bool IsEquipped { get; set; }
public int SlotIndex { get; set; }
public Godot.Collections.Dictionary<string, Variant> CustomProperties { get; set; }
public Godot.Collections.Dictionary<string, Variant> Serialize()
{
return new Godot.Collections.Dictionary<string, Variant>
{
["item_id"] = ItemId,
["count"] = Count,
["is_equipped"] = IsEquipped,
["slot_index"] = SlotIndex,
["custom_properties"] = CustomProperties ?? new Godot.Collections.Dictionary<string, Variant>()
};
}
public void Deserialize(Godot.Collections.Dictionary<string, Variant> data)
{
ItemId = data.GetValueOrDefault("item_id", "").AsString();
Count = data.GetValueOrDefault("count", 1).AsInt32();
IsEquipped = data.GetValueOrDefault("is_equipped", false).AsBool();
SlotIndex = data.GetValueOrDefault("slot_index", -1).AsInt32();
CustomProperties = data.GetValueOrDefault("custom_properties", new Godot.Collections.Dictionary<string, Variant>()).AsGodotDictionary();
}
public bool Validate()
{
return !string.IsNullOrEmpty(ItemId) && Count > 0;
}
}
}
9.3 本地化框架
本地化框架支持多语言内容和运行时语言切换。
9.3.1 LocalizationManager 实现
设计思路与原理
LocalizationManager提供游戏多语言本地化的完整解决方案,支持运行时语言切换和动态文本更新。
核心功能:
- 键值对存储:使用
Dictionary<string, string>存储翻译文本 - CSV数据源:从CSV文件加载多语言数据,便于策划和翻译人员编辑
- 参数替换:支持
{0}、{name}等占位符,实现动态内容插入 - 语言回退:当前语言缺失翻译时,自动使用默认语言
核心实现要点
- 单例模式:全局访问点,任何代码都可调用
Tr()翻译文本 - 富文本支持:解析
[color]、[b]等Godot富文本标签 - 事件通知:语言切换时触发事件,UI自动更新
- 异步加载:语言文件较大时使用后台线程加载
使用说明与最佳 practices
- 适用场景:多语言游戏、全球发行项目、Steam多区域发布
- 注意事项:
- 所有用户可见文本必须走翻译系统,硬编码是大忌
- 占位符数量要保持一致,避免
{0}和{1}顺序在不同语言中变化 - 字体需支持目标语言(如中文需要中文字体)
- 性能考虑:翻译文本缓存,避免重复查表
- 扩展建议:可接入第三方翻译服务(Google Translate API)实现自动翻译
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Godot;
namespace GameFramework.Data.Localization
{
/// <summary>
/// 本地化管理器
/// </summary>
public partial class LocalizationManager : Node
{
private static LocalizationManager _instance;
public static LocalizationManager Instance => _instance;
// 当前语言
[Export] public string CurrentLanguage = "zh_CN";
// 支持的语言列表
[Export] public Godot.Collections.Array<string> SupportedLanguages = new() { "zh_CN", "en_US", "ja_JP" };
// 默认语言
[Export] public string DefaultLanguage = "zh_CN";
// 本地化文本存储
private readonly Dictionary<string, Dictionary<string, string>> _translations = new();
// 本地化图片存储
private readonly Dictionary<string, Dictionary<string, Texture2D>> _localizedImages = new();
// 语言变更事件
[Signal]
public delegate void LanguageChangedEventHandler(string newLanguage);
// 回退文本(当找不到翻译时使用)
private const string FALLBACK_TEXT = "[MISSING: {0}]";
public override void _EnterTree()
{
if (_instance == null)
{
_instance = this;
}
}
public override void _Ready()
{
// 加载默认语言
LoadLanguage(CurrentLanguage);
}
/// <summary>
/// 切换语言
/// </summary>
public void SetLanguage(string languageCode)
{
if (!SupportedLanguages.Contains(languageCode))
{
GD.PushWarning($"不支持的语言: {languageCode},使用默认语言");
languageCode = DefaultLanguage;
}
if (languageCode == CurrentLanguage)
{
return;
}
// 加载新语言
LoadLanguage(languageCode);
CurrentLanguage = languageCode;
// 更新所有本地化节点
UpdateAllLocalizedNodes();
// 发送信号
EmitSignal(SignalName.LanguageChanged, languageCode);
GD.Print($"语言已切换为: {languageCode}");
}
/// <summary>
/// 获取本地化文本
/// </summary>
public string GetText(string key, params object[] args)
{
if (_translations.TryGetValue(CurrentLanguage, out var langDict))
{
if (langDict.TryGetValue(key, out var text))
{
if (args.Length > 0)
{
return string.Format(text, args);
}
return text;
}
}
// 尝试默认语言
if (CurrentLanguage != DefaultLanguage)
{
if (_translations.TryGetValue(DefaultLanguage, out var defaultLangDict))
{
if (defaultLangDict.TryGetValue(key, out var defaultText))
{
return defaultText;
}
}
}
// 返回回退文本
return string.Format(FALLBACK_TEXT, key);
}
/// <summary>
/// 获取本地化图片
/// </summary>
public Texture2D GetImage(string key)
{
if (_localizedImages.TryGetValue(CurrentLanguage, out var langDict))
{
if (langDict.TryGetValue(key, out var image))
{
return image;
}
}
// 尝试默认语言
if (CurrentLanguage != DefaultLanguage)
{
if (_localizedImages.TryGetValue(DefaultLanguage, out var defaultLangDict))
{
if (defaultLangDict.TryGetValue(key, out var defaultImage))
{
return defaultImage;
}
}
}
return null;
}
/// <summary>
/// 检查键是否存在
/// </summary>
public bool HasKey(string key)
{
if (_translations.TryGetValue(CurrentLanguage, out var langDict))
{
return langDict.ContainsKey(key);
}
return false;
}
/// <summary>
/// 添加翻译
/// </summary>
public void AddTranslation(string languageCode, string key, string text)
{
if (!_translations.ContainsKey(languageCode))
{
_translations[languageCode] = new Dictionary<string, string>();
}
_translations[languageCode][key] = text;
}
/// <summary>
/// 从CSV加载翻译
/// </summary>
public void LoadFromCsv(string csvPath)
{
if (!FileAccess.FileExists(csvPath))
{
GD.PushError($"CSV文件不存在: {csvPath}");
return;
}
var file = FileAccess.Open(csvPath, FileAccess.ModeFlags.Read);
var content = file.GetAsText();
file.Close();
// 解析CSV
var lines = content.Split('\n');
if (lines.Length < 2) return;
// 第一行是语言代码
var headers = lines[0].Split(',');
var languageCodes = new List<string>();
for (int i = 1; i < headers.Length; i++)
{
languageCodes.Add(headers[i].Trim());
if (!_translations.ContainsKey(headers[i].Trim()))
{
_translations[headers[i].Trim()] = new Dictionary<string, string>();
}
}
// 解析数据行
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrEmpty(line)) continue;
var values = ParseCsvLine(line);
if (values.Count < 2) continue;
var key = values[0].Trim();
for (int j = 1; j < values.Count && j - 1 < languageCodes.Count; j++)
{
var langCode = languageCodes[j - 1];
var text = values[j].Trim().Replace("\"\"", "\""); // 处理转义引号
_translations[langCode][key] = text;
}
}
}
/// <summary>
/// 从JSON加载翻译
/// </summary>
public void LoadFromJson(string jsonPath)
{
if (!FileAccess.FileExists(jsonPath))
{
GD.PushError($"JSON文件不存在: {jsonPath}");
return;
}
var file = FileAccess.Open(jsonPath, FileAccess.ModeFlags.Read);
var json = file.GetAsText();
file.Close();
var parsed = Json.ParseString(json);
if (parsed.VariantType != Variant.Type.Dictionary)
{
GD.PushError("JSON格式无效");
return;
}
var data = parsed.AsGodotDictionary();
foreach (var langPair in data)
{
var langCode = langPair.Key.AsString();
var translations = langPair.Value.AsGodotDictionary();
if (!_translations.ContainsKey(langCode))
{
_translations[langCode] = new Dictionary<string, string>();
}
foreach (var pair in translations)
{
_translations[langCode][pair.Key.AsString()] = pair.Value.AsString();
}
}
}
/// <summary>
/// 获取当前语言的名称
/// </summary>
public string GetLanguageDisplayName(string languageCode)
{
var displayNames = new Dictionary<string, string>
{
["zh_CN"] = "简体中文",
["zh_TW"] = "繁体中文",
["en_US"] = "English",
["ja_JP"] = "日本語",
["ko_KR"] = "한국어",
["fr_FR"] = "Français",
["de_DE"] = "Deutsch",
["es_ES"] = "Español",
["ru_RU"] = "Русский"
};
return displayNames.GetValueOrDefault(languageCode, languageCode);
}
/// <summary>
/// 获取支持的语言名称列表
/// </summary>
public Godot.Collections.Dictionary<string, string> GetSupportedLanguages()
{
var result = new Godot.Collections.Dictionary<string, string>();
foreach (var lang in SupportedLanguages)
{
result[lang] = GetLanguageDisplayName(lang);
}
return result;
}
private void LoadLanguage(string languageCode)
{
// 确保语言字典存在
if (!_translations.ContainsKey(languageCode))
{
_translations[languageCode] = new Dictionary<string, string>();
}
// 从文件加载(如果文件存在)
var csvPath = $"res://localization/{languageCode}.csv";
if (FileAccess.FileExists(csvPath))
{
LoadFromCsv(csvPath);
}
var jsonPath = $"res://localization/{languageCode}.json";
if (FileAccess.FileExists(jsonPath))
{
LoadFromJson(jsonPath);
}
}
private void UpdateAllLocalizedNodes()
{
// 遍历场景树中的所有本地化节点
var nodes = GetTree().GetNodesInGroup("localized");
foreach (var node in nodes)
{
if (node is LocalizedLabel label)
{
label.UpdateText();
}
else if (node is LocalizedTexture texture)
{
texture.UpdateTexture();
}
else if (node is LocalizedButton button)
{
button.UpdateText();
}
}
}
private List<string> ParseCsvLine(string line)
{
var result = new List<string>();
var current = "";
var inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
current += '"';
i++; // 跳过下一个引号
}
else
{
inQuotes = !inQuotes;
}
}
else if (c == ',' && !inQuotes)
{
result.Add(current);
current = "";
}
else
{
current += c;
}
}
result.Add(current);
return result;
}
}
}
9.3.2 本地化节点组件
设计思路与原理
LocalizedLabel和LocalizedButton是本地化系统的UI组件扩展,让Godot原生UI控件自动支持多语言。
组件化优势:
- 声明式配置:在编辑器中直接设置翻译键,无需代码
- 自动更新:语言切换时自动刷新显示文本
- 参数绑定:支持Inspector中配置格式化参数
- 继承扩展:继承原生Label/Button,保留所有原有功能
核心实现要点
- [GlobalClass]特性:让组件在Godot编辑器节点面板中可见
- 参数序列化:使用
Array<string>存储格式化参数,支持Inspector编辑 - 语言变更监听:订阅
LocalizationManager事件实现自动更新 - 运行时切换:可在
_Ready后动态修改LocalizationKey
使用说明与最佳 practices
- 适用场景:所有显示文本的UI元素(按钮、标签、提示)
- 注意事项:
- 确保
LocalizationKey在CSV文件中存在 - 动态参数在代码中设置时使用
SetFormatArgs() - 富文本Label需额外处理bbcode标签
- 确保
- 性能考虑:
_Ready时只读取一次翻译,避免每帧调用 - 扩展建议:可创建
LocalizedTextureRect支持本地化图片切换
using Godot;
using GameFramework.Data.Localization;
namespace GameFramework.Data.Localization
{
/// <summary>
/// 本地化标签
/// </summary>
[GlobalClass]
public partial class LocalizedLabel : Label
{
[Export] public string LocalizationKey;
[Export] public bool AutoUpdate = true;
[Export] public Godot.Collections.Array<string> FormatArgs = new();
public override void _Ready()
{
AddToGroup("localized");
if (AutoUpdate)
{
UpdateText();
}
// 监听语言变更
if (LocalizationManager.Instance != null)
{
LocalizationManager.Instance.LanguageChanged += OnLanguageChanged;
}
}
public void UpdateText()
{
if (string.IsNullOrEmpty(LocalizationKey))
{
return;
}
if (LocalizationManager.Instance == null)
{
Text = LocalizationKey;
return;
}
if (FormatArgs.Count > 0)
{
var args = new object[FormatArgs.Count];
for (int i = 0; i < FormatArgs.Count; i++)
{
args[i] = FormatArgs[i];
}
Text = LocalizationManager.Instance.GetText(LocalizationKey, args);
}
else
{
Text = LocalizationManager.Instance.GetText(LocalizationKey);
}
}
public void SetKey(string key, params object[] args)
{
LocalizationKey = key;
FormatArgs.Clear();
foreach (var arg in args)
{
FormatArgs.Add(arg?.ToString() ?? "");
}
UpdateText();
}
private void OnLanguageChanged(string newLanguage)
{
if (AutoUpdate)
{
UpdateText();
}
}
public override void _ExitTree()
{
if (LocalizationManager.Instance != null)
{
LocalizationManager.Instance.LanguageChanged -= OnLanguageChanged;
}
}
}
/// <summary>
/// 本地化按钮
/// </summary>
[GlobalClass]
public partial class LocalizedButton : Button
{
[Export] public string LocalizationKey;
[Export] public bool AutoUpdate = true;
public override void _Ready()
{
AddToGroup("localized");
if (AutoUpdate)
{
UpdateText();
}
if (LocalizationManager.Instance != null)
{
LocalizationManager.Instance.LanguageChanged += OnLanguageChanged;
}
}
public void UpdateText()
{
if (string.IsNullOrEmpty(LocalizationKey) || LocalizationManager.Instance == null)
{
return;
}
Text = LocalizationManager.Instance.GetText(LocalizationKey);
}
private void OnLanguageChanged(string newLanguage)
{
if (AutoUpdate)
{
UpdateText();
}
}
public override void _ExitTree()
{
if (LocalizationManager.Instance != null)
{
LocalizationManager.Instance.LanguageChanged -= OnLanguageChanged;
}
}
}
/// <summary>
/// 本地化纹理
/// </summary>
[GlobalClass]
public partial class LocalizedTexture : Sprite2D
{
[Export] public string LocalizationKey;
[Export] public bool AutoUpdate = true;
public override void _Ready()
{
AddToGroup("localized");
if (AutoUpdate)
{
UpdateTexture();
}
if (LocalizationManager.Instance != null)
{
LocalizationManager.Instance.LanguageChanged += OnLanguageChanged;
}
}
public void UpdateTexture()
{
if (string.IsNullOrEmpty(LocalizationKey) || LocalizationManager.Instance == null)
{
return;
}
var texture = LocalizationManager.Instance.GetImage(LocalizationKey);
if (texture != null)
{
this.Texture = texture;
}
}
private void OnLanguageChanged(string newLanguage)
{
if (AutoUpdate)
{
UpdateTexture();
}
}
public override void _ExitTree()
{
if (LocalizationManager.Instance != null)
{
LocalizationManager.Instance.LanguageChanged -= OnLanguageChanged;
}
}
}
}
9.3.3 CSV本地化文件示例
设计思路与原理
CSV(Comma-Separated Values)格式是游戏本地化的标准数据源格式,具有以下优势:
CSV格式优势:
- 通用兼容:Excel、Google Sheets、文本编辑器都能打开编辑
- 版本友好:纯文本格式便于Git版本控制和差异对比
- 翻译友好:翻译人员无需了解代码,直接在表格中填写
- 可扩展:添加新语言只需增加一列
文件结构约定:
- 第一列key:翻译的唯一标识符,代码中使用此key查找
- 后续列语言:每列一种语言,列名为语言代码(zh_CN, en_US等)
- 占位符支持:使用
{0},{1}等表示动态参数位置 - 编码格式:UTF-8编码,确保多语言字符正常显示
使用说明与最佳 practices
- 适用场景:中小型游戏(翻译条目<10000)、多语言同时开发
- 工作流程:
- 策划在Excel中维护master表格
- 导出为CSV放入项目
- 代码中使用key引用
- 翻译完成后更新CSV
- 注意事项:
- key命名规范:
模块_功能_具体文本(如ui_settings_audio_volume) - 避免key重复,确保唯一性
- 逗号和引号需正确转义
- 长文本可包含换行符
\n
- key命名规范:
- 替代方案:大型项目可考虑Gettext(.po)、JSON、YAML格式
key,zh_CN,en_US,ja_JP
ui_play,开始游戏,Play Game,ゲーム開始
ui_settings,设置,Settings,設定
ui_exit,退出,Exit,終了
ui_continue,继续,Continue,続ける
ui_new_game,新游戏,New Game,新規ゲーム
ui_load_game,载入游戏,Load Game,ロード
dialog_yes,是,Yes,はい
dialog_no,否,No,いいえ
menu_main,主菜单,Main Menu,メインメニュー
menu_pause,暂停,Pause,一時停止
inventory_title,背包,Inventory,インベントリ
stats_level,等级: {0},Level: {0},レベル: {0}
stats_exp,经验: {0}/{1},EXP: {0}/{1},経験値: {0}/{1}
使用说明与最佳 practices
- 适用场景:多语言游戏、全球化发行的独立游戏
- 注意事项:
- key命名采用
模块_功能_具体文本层次结构 - 占位符索引从0开始,不同语言可调整参数顺序
- 文本长度差异:中文简洁,英文中等,德文往往较长
- 特殊字符(逗号、换行)需用引号包裹
- key命名采用
- 性能考虑:游戏启动时一次性加载,运行时只读访问
- 扩展建议:可添加
context列提供翻译上下文说明
9.4 本章小结
本章介绍了三个核心数据管理模块:
Resource驱动配置系统:利用Godot内置的Resource系统管理配置,支持编辑器可视化配置、类型安全和版本控制。
存档系统:提供完整的存档功能,支持多槽位、多种格式(JSON/Binary/Encrypted)和版本兼容检查。
本地化框架:支持多语言切换、动态文本更新、参数化翻译和资源本地化。
这些系统可以组合使用:
- 配置系统保存玩家偏好设置
- 存档系统保存游戏进度,同时保存当前语言设置
- 本地化框架自动读取配置中的语言偏好
合理运用这些系统可以构建出专业、易于维护的数据管理体系。