第9章 配置与数据

配置与数据管理是游戏开发的重要组成部分。本章介绍Resource驱动配置系统、存档系统和本地化框架,帮助开发者高效管理游戏数据和配置。

9.1 Resource驱动配置系统

9.1.0 设计原理与思路

Resource系统的核心优势

Godot的Resource系统是游戏配置管理的理想选择,其设计理念源于数据驱动开发(Data-Driven Development)。相比传统的硬编码或外部配置文件,Resource具有以下独特优势:

传统方式 vs Resource方式

硬编码方式:
  代码中写死数值 -> 修改需重新编译 -> 测试周期长

外部配置文件(JSON/INI):
  运行时解析文件 -> 无类型检查 -> 错误发现晚

Resource方式:
  可视化编辑器配置 -> 类型安全编译检查 -> 热重载支持

Resource系统采用引用计数的内存管理机制,相同配置资源可被多个对象共享,内存占用更优。同时,Godot编辑器对Resource提供完整的可视化支持,策划人员可直接在编辑器中调整数值,无需编写代码。

数据驱动设计的核心理念

数据驱动设计将游戏逻辑与数据分离,使游戏行为由配置决定而非代码硬编码。这种模式带来三大好处:

  1. 快速迭代:策划人员独立调整数值,程序员专注功能实现
  2. 易于平衡:通过配置调整游戏难度、经济系统等,避免频繁编译
  3. 扩展性强:新增配置类型无需修改现有代码,符合开闭原则

存档系统的架构选择

在设计存档系统时,我们面临三个关键决策:

存档架构决策树

存储格式选择:
  JSON - 可读性强,便于调试
  Binary - 体积小,加载快
  Encrypted - 防篡改,安全性高

数据组织方式:
  集中式 - 单一存档文件,便于备份
  分散式 - 多文件分离,便于增量更新

版本兼容性:
  强兼容 - 支持旧存档自动升级
  弱兼容 - 版本不匹配时提示重新游戏

本框架采用"加密二进制为主、JSON调试为辅"的策略,兼顾安全性与开发便利性。数据组织采用集中式元数据+分散式内容的分层架构,便于扩展和版本管理。

9.1.1 使用场景分析

Resource配置的典型应用场景

  1. 游戏平衡数据管理

    • 武器属性配置:伤害、射速、弹药容量
    • 角色成长曲线:每级所需经验、属性成长率
    • 经济系统参数:掉落率、商店价格倍率
  2. 关卡配置

    • 关卡布局数据:敌人出生点、道具位置
    • 难度参数调整:敌人血量倍率、AI侵略性
    • 环境效果配置:光照、天气、背景音乐
  3. 玩家偏好设置

    • 图形选项:分辨率、画质等级、抗锯齿
    • 音频设置:各通道音量、3D音效开关
    • 控制配置:鼠标灵敏度、按键映射

存档系统的使用场景

  1. 玩家进度持久化

    • 主线进度:当前章节、任务完成状态
    • 角色数据:等级、经验、装备、技能
    • 收集内容:解锁的角色、成就、图鉴
  2. 设置持久化

    • 游戏设置自动保存,下次启动恢复
    • 多语言偏好与存档绑定
    • 自定义键位配置

本地化框架的应用

  1. 多语言游戏发布

    • 同时支持简体中文、繁体中文、英文、日文等
    • 运行时语言切换无需重启游戏
    • 地区特定内容(节日活动、文化元素)
  2. 动态文本管理

    • 参数化文本:“等级: {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. 创建配置资源类
// 1. 继承GameConfig基类
[GlobalClass]
public partial class WeaponConfig : GameConfig
{
    [Export] public string WeaponName;
    [Export] public int Damage;
    [Export] public float FireRate;
}
  1. 在ConfigManager中注册默认配置路径
public override void _Ready()
{
    // 注册武器配置的默认路径
    RegisterDefaultConfig<WeaponConfig>("res://configs/default_weapon.tres");

    // 加载所有配置
    LoadOrCreateDefaultConfigs();
}
  1. 在游戏代码中使用配置
var weaponConfig = ConfigManager.Instance.GetConfig<WeaponConfig>();
GD.Print($"武器伤害: {weaponConfig.Damage}");
  1. 修改并保存配置
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类型,你可以:

  1. 类型安全:编译时检查配置字段,避免拼写错误
  2. 可视化编辑:在Godot编辑器中直接编辑配置值
  3. 资源引用:支持引用其他资源(如纹理、音频)
  4. 热重载:运行时修改配置自动生效(开发模式)

核心实现要点

  1. 继承Resource类并添加[GlobalClass]特性
  2. 使用[Export]特性暴露可编辑字段
  3. 使用[ExportGroup]组织相关字段
  4. 实现Validate方法验证配置有效性
  5. 派生类可以添加特定领域的配置字段
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

  • 适用场景

    • 游戏设置(音量、画质、控制)
    • 游戏配置(难度、规则参数)
    • 关卡配置(敌人数量、刷怪点)
    • 数据表(物品属性、技能数值)
  • 使用方式

    1. 创建继承Resource的配置类
    2. 添加[Export]字段
    3. 在Godot编辑器中创建资源文件
    4. 运行时加载并使用配置
  • 注意事项

    1. 使用[GlobalClass]确保类在编辑器中可见
    2. 合理分组相关字段使用[ExportGroup]
    3. 使用PropertyHint提供范围和枚举提示
    4. 实现验证方法确保配置有效性
  • 性能考虑:Resource在Godot中自动缓存,多次加载返回同一实例


9.1.2 配置管理器

设计思路与原理

ConfigManager是配置系统的集中管理器,提供配置的加载、保存、访问和监听功能。作为单例AutoLoad节点,它确保游戏任何地方都能访问配置。

核心职责:

  1. 配置加载:从资源文件或JSON加载配置
  2. 配置保存:将运行时修改保存到磁盘
  3. 变更监听:通知订阅者配置变化
  4. 热重载:开发模式下自动重载修改的配置

核心实现要点

  1. 单例模式提供全局访问
  2. 泛型方法GetConfig<T>提供类型安全访问
  3. 事件系统通知配置变化
  4. 支持运行时重新加载配置
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

  • 适用场景

    • 集中管理游戏所有配置
    • 运行时动态加载和保存配置
    • 配置变更通知和同步
    • 配置备份和恢复
  • 使用方式

    1. 将ConfigManager添加为AutoLoad
    2. 调用LoadConfig<T>加载配置
    3. 使用GetConfig<T>获取配置实例
    4. 配置修改后调用SaveConfig<T>保存
  • 注意事项

    1. 配置Resource在内存中共享,修改会影响所有使用者
    2. 使用Duplicate()创建配置副本避免意外修改
    3. 保存配置是IO操作,考虑异步或延迟保存
    4. 版本控制中排除用户配置,只保留默认配置

9.1.3 配置导出属性

设计思路与原理

[Export]特性允许在Godot编辑器中直接编辑节点的属性。结合自定义Resource类型,可以实现强大的配置可视化编辑功能。

核心优势:

  1. 可视化编辑:在编辑器中直接调整配置值
  2. 实时预览:修改配置立即看到效果
  3. 类型安全:编译时检查配置类型
  4. 资源引用:支持引用其他资源(纹理、音频等)

核心实现要点

  1. 使用[Export]暴露Resource类型的配置字段
  2. _Ready中应用配置
  3. 使用PropertyHint提供编辑器提示
  4. 监听配置变化实现热重载
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

  • 适用场景

    • 在编辑器中配置游戏参数
    • 关卡特定的配置覆盖
    • 实时预览配置效果
    • 配置热重载
  • 使用方式

    1. 在场景中创建ConfigExporter节点
    2. [Export]字段分配配置资源
    3. 在编辑器中修改配置值
    4. 运行时自动应用配置
  • 注意事项

    1. 导出的配置是引用,修改会影响所有使用者
    2. _Ready中应用配置确保初始化正确
    3. 考虑实现配置变化的回调
    4. 使用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接口定义了存档数据的标准契约,确保所有需要存档的数据都遵循统一的序列化和反序列化规范。

核心设计原则:

  1. 版本控制:通过Version属性支持存档格式升级和向后兼容
  2. 数据验证Validate方法确保存档数据完整性,防止损坏的存档导致游戏崩溃
  3. 字典序列化:使用Godot的Dictionary<string, Variant>作为中间格式,便于转换为JSON或二进制
  4. 解耦设计:接口抽象让存档系统与具体业务数据完全解耦

核心实现要点

  1. SaveId唯一标识:每个存档数据必须有唯一ID,用于区分不同数据类型
  2. 版本号管理:默认版本"1.0.0",数据迁移时通过版本号判断是否需要转换
  3. 抽象基类SaveDataBase提供默认实现,减少重复代码
  4. Variant类型:使用Godot的Variant类型支持多种数据类型的统一处理

使用说明与最佳实践

  • 适用场景:玩家进度、游戏设置、解锁内容、统计数据等持久化数据
  • 注意事项
    1. 序列化时避免循环引用
    2. 复杂对象需要扁平化为基本类型
    3. 敏感数据(如金币数量)应在服务器验证
  • 性能考虑:大数据量时考虑分块存档,避免一次性序列化过多数据
  • 扩展建议:可添加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是游戏存档系统的核心组件,负责统一管理所有存档数据的序列化、存储和加载。

核心职责:

  1. 多格式支持:支持JSON(可读)、Binary(紧凑)、Encrypted(安全)三种存档格式
  2. 多槽位管理:提供多个存档槽位,支持自动存档和手动存档分离
  3. 元数据管理:存储存档时间、游戏时长等元信息,无需加载完整数据即可显示
  4. 加密保护:敏感数据可选择AES加密,防止存档被篡改

核心实现要点

  1. 异步操作:存档/读档使用async/await,避免阻塞主线程造成卡顿
  2. 备份机制:保存前先备份旧存档,防止写入失败导致数据丢失
  3. 压缩支持:Binary格式可结合压缩减少存储空间
  4. 版本迁移:检测到旧版本存档时自动调用迁移逻辑

使用说明与最佳 practices

  • 适用场景:RPG进度保存、策略游戏回合存档、 Roguelike死亡存档
  • 注意事项
    1. 定期自动存档(如每5分钟)防止意外退出丢失进度
    2. 关键节点(通关、获得稀有物品)前自动备份
    3. 云存档需处理冲突(本地较新 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展示了一个典型的游戏存档数据实现,包含玩家状态、库存、任务进度等核心游戏数据。

数据分类策略:

  1. 核心数据:玩家名称、等级、位置(必须保存)
  2. 进度数据:任务完成状态、剧情章节(游戏推进关键)
  3. 收集数据:物品、装备、解锁内容(玩家投入时间)
  4. 统计数据:游戏时长、死亡次数、击杀数(成就展示)

核心实现要点

  1. 扁平化存储:将复杂对象(如Vector3)拆分为x/y/z三个基本值
  2. 集合处理:列表数据使用Array<Variant>存储
  3. 版本标记Version属性便于后续数据迁移
  4. 默认值处理:反序列化时检查key是否存在,提供默认值

使用说明与最佳 practices

  • 适用场景:RPG角色数据、动作游戏进度、模拟经营存档
  • 注意事项
    1. 敏感数值(金币、经验)应加校验和防止修改
    2. 避免保存运行时临时数据(如缓存、计算结果)
    3. 引用类型(如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提供游戏多语言本地化的完整解决方案,支持运行时语言切换和动态文本更新。

核心功能:

  1. 键值对存储:使用Dictionary<string, string>存储翻译文本
  2. CSV数据源:从CSV文件加载多语言数据,便于策划和翻译人员编辑
  3. 参数替换:支持{0}{name}等占位符,实现动态内容插入
  4. 语言回退:当前语言缺失翻译时,自动使用默认语言

核心实现要点

  1. 单例模式:全局访问点,任何代码都可调用Tr()翻译文本
  2. 富文本支持:解析[color][b]等Godot富文本标签
  3. 事件通知:语言切换时触发事件,UI自动更新
  4. 异步加载:语言文件较大时使用后台线程加载

使用说明与最佳 practices

  • 适用场景:多语言游戏、全球发行项目、Steam多区域发布
  • 注意事项
    1. 所有用户可见文本必须走翻译系统,硬编码是大忌
    2. 占位符数量要保持一致,避免{0}{1}顺序在不同语言中变化
    3. 字体需支持目标语言(如中文需要中文字体)
  • 性能考虑:翻译文本缓存,避免重复查表
  • 扩展建议:可接入第三方翻译服务(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 本地化节点组件

设计思路与原理

LocalizedLabelLocalizedButton是本地化系统的UI组件扩展,让Godot原生UI控件自动支持多语言。

组件化优势:

  1. 声明式配置:在编辑器中直接设置翻译键,无需代码
  2. 自动更新:语言切换时自动刷新显示文本
  3. 参数绑定:支持Inspector中配置格式化参数
  4. 继承扩展:继承原生Label/Button,保留所有原有功能

核心实现要点

  1. [GlobalClass]特性:让组件在Godot编辑器节点面板中可见
  2. 参数序列化:使用Array<string>存储格式化参数,支持Inspector编辑
  3. 语言变更监听:订阅LocalizationManager事件实现自动更新
  4. 运行时切换:可在_Ready后动态修改LocalizationKey

使用说明与最佳 practices

  • 适用场景:所有显示文本的UI元素(按钮、标签、提示)
  • 注意事项
    1. 确保LocalizationKey在CSV文件中存在
    2. 动态参数在代码中设置时使用SetFormatArgs()
    3. 富文本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格式优势:

  1. 通用兼容:Excel、Google Sheets、文本编辑器都能打开编辑
  2. 版本友好:纯文本格式便于Git版本控制和差异对比
  3. 翻译友好:翻译人员无需了解代码,直接在表格中填写
  4. 可扩展:添加新语言只需增加一列

文件结构约定:

  1. 第一列key:翻译的唯一标识符,代码中使用此key查找
  2. 后续列语言:每列一种语言,列名为语言代码(zh_CN, en_US等)
  3. 占位符支持:使用{0}, {1}等表示动态参数位置
  4. 编码格式:UTF-8编码,确保多语言字符正常显示

使用说明与最佳 practices

  • 适用场景:中小型游戏(翻译条目<10000)、多语言同时开发
  • 工作流程
    1. 策划在Excel中维护master表格
    2. 导出为CSV放入项目
    3. 代码中使用key引用
    4. 翻译完成后更新CSV
  • 注意事项
    1. key命名规范:模块_功能_具体文本(如ui_settings_audio_volume
    2. 避免key重复,确保唯一性
    3. 逗号和引号需正确转义
    4. 长文本可包含换行符\n
  • 替代方案:大型项目可考虑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

  • 适用场景:多语言游戏、全球化发行的独立游戏
  • 注意事项
    1. key命名采用模块_功能_具体文本层次结构
    2. 占位符索引从0开始,不同语言可调整参数顺序
    3. 文本长度差异:中文简洁,英文中等,德文往往较长
    4. 特殊字符(逗号、换行)需用引号包裹
  • 性能考虑:游戏启动时一次性加载,运行时只读访问
  • 扩展建议:可添加context列提供翻译上下文说明

9.4 本章小结

本章介绍了三个核心数据管理模块:

  1. Resource驱动配置系统:利用Godot内置的Resource系统管理配置,支持编辑器可视化配置、类型安全和版本控制。

  2. 存档系统:提供完整的存档功能,支持多槽位、多种格式(JSON/Binary/Encrypted)和版本兼容检查。

  3. 本地化框架:支持多语言切换、动态文本更新、参数化翻译和资源本地化。

这些系统可以组合使用:

  • 配置系统保存玩家偏好设置
  • 存档系统保存游戏进度,同时保存当前语言设置
  • 本地化框架自动读取配置中的语言偏好

合理运用这些系统可以构建出专业、易于维护的数据管理体系。