Unity 技能编辑器与扁平数据缓存设计

本文介绍如何设计一个高性能的技能编辑器,使用扁平数据结构(FlatBuffers)缓存技能数据,实现快速加载和运行时高效访问。

一、设计概述

1.1 为什么使用扁平数据

数据结构 加载速度 内存占用 序列化 适用场景
类对象 大(对象头) 简单 开发期
ScriptableObject 内置 Unity 配置
JSON 简单 网络传输
FlatBuffers 需工具 大量数据
自定义二进制 最快 最小 自定义 追求性能

1.2 技能编辑器架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌───────────────────────────────────────────────────────────────┐
│ 技能编辑器架构 │
├───────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ 编辑 ┌──────────────┐ │
│ │ 可视化编辑 │ ───────────────>│ 技能配置数据 │ │
│ │ 界面 │ │ (Scriptable) │ │
│ └─────────────┘ └──────────────┘ │
│ │ │ │
│ │ 导出 │ 序列化 │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ FlatBuffers │ <───────────────│ 二进制数据 │ │
│ │ .bytes 文件 │ 加载 │ (.bytes) │ │
│ └─────────────┘ └──────────────┘ │
│ │ │
│ │ 运行时加载 │
│ ▼ │
│ ┌─────────────┐ 读取 ┌──────────────┐ │
│ │ 技能系统 │ <──────────────│ 缓存管理器 │ │
│ │ │ │ │ │
│ └─────────────┘ └──────────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘

二、技能数据结构设计

2.1 技能配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
using UnityEngine;
using System.Collections.Generic;

/// <summary>
/// 技能配置数据
/// </summary>
[CreateAssetMenu(fileName = "NewSkill", menuName = "Game/Skill Config")]
public class SkillConfig : ScriptableObject
{
[Header("基础信息")]
public int skillId;
public string skillName = "新技能";
public SkillType skillType = SkillType.Active;
public Sprite icon;

[Header("属性")]
[Range(0, 100)]
public int damage = 10;
[Range(0, 500)]
public float range = 5f;
[Range(0, 60)]
public float cooldown = 10f;

[Header("效果")]
public SkillEffect[] effects;
public SkillCondition[] conditions;

[Header("动画")]
public string animationTrigger;
public float animationDuration = 1f;

[Header("编辑器信息")]
[System.NonSerialized]
public string editorColor = "#FFFFFF";
}

/// <summary>
/// 技能类型
/// </summary>
public enum SkillType
{
Passive, // 被动技能
Active, // 主动技能
Channel, // 引导技能
Toggle // 切换技能
}

/// <summary>
/// 技能效果
/// </summary>
[System.Serializable]
public class SkillEffect
{
public EffectType type;
public float value;
public float duration;
public string targetTag;
}

public enum EffectType
{
Damage, // 伤害
Heal, // 治疗
Buff, // 增益
Debuff, // 减益
Knockback, // 击退
Stun // 眩晕
}

/// <summary>
/// 技能释放条件
/// </summary>
[System.Serializable]
public class SkillCondition
{
public ConditionType type;
public float minValue;
public float maxValue;
}

public enum ConditionType
{
HealthGreaterThan, // 生命值大于
HealthLessThan, // 生命值小于
ManaGreaterThan, // 魔法值大于
TargetInRange, // 目标在范围内
CooldownReady // 冷却就绪
}

2.2 扁平数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/// <summary>
/// 技能扁平数据 - 运行时使用的紧凑结构
/// </summary>
public struct SkillFlatData
{
// 基础信息 (12 bytes)
public int skillId;
public short skillType;
public short flags; // 位标记:isChanneling, isToggle, etc.

// 属性 (16 bytes)
public short damage;
public short range;
public short cooldown; // 以秒为单位 * 100
public short animationId;

// 效果数据偏移 (8 bytes)
public int effectsOffset; // 在 effects 数组中的偏移
public short effectCount;
public short padding;

// 总大小: 36 bytes
}

/// <summary>
/// 技能效果扁平数据
/// </summary>
public struct SkillEffectFlatData
{
public short effectType;
public short value; // 值 * 100
public short duration; // 以秒为单位 * 100
public short targetTagId;

// 总大小: 8 bytes
}

/// <summary>
/// 技能数据库 - 存储所有扁平数据
/// </summary>
public class SkillDatabase
{
private SkillFlatData[] skills;
private SkillEffectFlatData[] effects;
private Dictionary<int, int> skillIdToIndex;

public SkillDatabase(byte[] flatData)
{
LoadFromFlatBuffer(flatData);
}

public ref SkillFlatData GetSkill(int skillId)
{
if (skillIdToIndex.TryGetValue(skillId, out int index))
{
return ref skills[index];
}
throw new System.KeyNotFoundException($"Skill {skillId} not found");
}

public Span<SkillEffectFlatData> GetEffects(SkillFlatData skill)
{
return effects.AsSpan(skill.effectsOffset, skill.effectCount);
}

private void LoadFromFlatBuffer(byte[] data)
{
// 解析扁平数据
// 实际实现根据序列化格式而定
}
}

三、编辑器实现

3.1 技能编辑器窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
using UnityEngine;
using UnityEditor;
using System.Linq;

[CustomEditor(typeof(SkillConfig))]
public class SkillConfigEditor : Editor
{
private SkillConfig skill;
private SerializedObject serializedSkill;
private SerializedProperty effectsProperty;
private SerializedProperty conditionsProperty;

private GUIStyle headerStyle;
private GUIStyle boxStyle;

void OnEnable()
{
skill = (SkillConfig)target;
serializedSkill = new SerializedObject(target);
effectsProperty = serializedSkill.FindProperty("effects");
conditionsProperty = serializedSkill.FindProperty("conditions");

// 初始化样式
InitStyles();
}

private void InitStyles()
{
headerStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 14,
fontStyle = FontStyle.Bold
};

boxStyle = new GUIStyle(EditorStyles.helpBox)
{
padding = new RectOffset(10, 10, 10, 10)
};
}

public override void OnInspectorGUI()
{
serializedSkill.Update();

// 绘制头部信息
DrawHeader();

EditorGUILayout.Space();

// 基础信息
DrawBasicInfo();

EditorGUILayout.Space();

// 属性设置
DrawProperties();

EditorGUILayout.Space();

// 效果列表
DrawEffects();

EditorGUILayout.Space();

// 条件列表
DrawConditions();

EditorGUILayout.Space();

// 预览
DrawPreview();

serializedSkill.ApplyModifiedProperties();
}

private void DrawHeader()
{
// 技能图标和名称
EditorGUILayout.BeginHorizontal();
EditorGUILayout.ObjectField(skill.icon, typeof(Sprite), false, GUILayout.Width(64));
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField($"ID: {skill.skillId}", EditorStyles.boldLabel);
EditorGUILayout.LabelField(skill.skillName, EditorStyles.largeLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
}

private void DrawBasicInfo()
{
EditorGUILayout.LabelField("基础信息", headerStyle);
EditorGUILayout.BeginVertical(boxStyle);
EditorGUILayout.PropertyField(serializedSkill.FindProperty("skillId"));
EditorGUILayout.PropertyField(serializedSkill.FindProperty("skillName"));
EditorGUILayout.PropertyField(serializedSkill.FindProperty("skillType"));
EditorGUILayout.PropertyField(serializedSkill.FindProperty("icon"));
EditorGUILayout.EndVertical();
}

private void DrawProperties()
{
EditorGUILayout.LabelField("属性设置", headerStyle);
EditorGUILayout.BeginVertical(boxStyle);

var damageProp = serializedSkill.FindProperty("damage");
var rangeProp = serializedSkill.FindProperty("range");
var cooldownProp = serializedSkill.FindProperty("cooldown");

EditorGUILayout.PropertyField(damageProp);
EditorGUILayout.PropertyField(rangeProp);
EditorGUILayout.PropertyField(cooldownProp);

// 计算战斗力
int combatPower = skill.damage * 2 + (int)(skill.range * 5);
EditorGUILayout.HelpBox($"战斗力指数: {combatPower}", MessageType.Info);

EditorGUILayout.EndVertical();
}

private void DrawEffects()
{
EditorGUILayout.LabelField("技能效果", headerStyle);
EditorGUILayout.BeginVertical(boxStyle);

EditorGUILayout.PropertyField(effectsProperty, true);

if (effectsProperty.arraySize > 0)
{
EditorGUILayout.Space();
if (GUILayout.Button("清空所有效果"))
{
effectsProperty.ClearArray();
}
}

EditorGUILayout.EndVertical();
}

private void DrawConditions()
{
EditorGUILayout.LabelField("释放条件", headerStyle);
EditorGUILayout.BeginVertical(boxStyle);
EditorGUILayout.PropertyField(conditionsProperty, true);
EditorGUILayout.EndVertical();
}

private void DrawPreview()
{
EditorGUILayout.LabelField("预览", headerStyle);
EditorGUILayout.BeginVertical(boxStyle);

// 绘制技能范围预览
DrawRangePreview();

EditorGUILayout.EndVertical();
}

private void DrawRangePreview()
{
Rect rect = EditorGUILayout.GetControlRect(false, 200);
GUI.Box(rect, "范围预览");

// 绘制范围圆
Vector2 center = new Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2);
float maxRadius = Mathf.Min(rect.width, rect.height) / 2 - 10;
float displayRadius = (skill.range / 10f) * maxRadius;

Handles.BeginGUI();
Handles.color = new Color(0, 1, 0, 0.5f);
Handles.DrawWireDisc(center, Vector3.forward, displayRadius);
Handles.color = new Color(1, 0, 0, 0.3f);
Handles.DrawSolidDisc(center, Vector3.forward, displayRadius * 0.3f);
Handles.EndGUI();

// 显示范围文本
GUI.Label(new Rect(center.x - 50, center.y - 10, 100, 20),
$"范围: {skill.range}m", EditorStyles.centeredGreyMiniLabel);
}
}

3.2 技能管理器窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;

public class SkillManagerWindow : EditorWindow
{
private Vector2 scrollPosition;
private SkillConfig selectedSkill;
private string searchFilter = "";

[MenuItem("Tools/Skill Manager")]
public static void ShowWindow()
{
var window = GetWindow<SkillManagerWindow>();
window.titleContent = new GUIContent("技能管理器");
window.Show();
}

void OnGUI()
{
// 工具栏
DrawToolbar();

EditorGUILayout.Space();

// 分栏布局
EditorGUILayout.BeginHorizontal();

// 左侧:技能列表
DrawSkillList();

// 右侧:技能详情
if (selectedSkill != null)
{
DrawSkillDetail();
}

EditorGUILayout.EndHorizontal();
}

private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);

// 搜索框
searchFilter = EditorGUILayout.TextField("搜索: ", searchFilter, EditorStyles.toolbarSearchField);

GUILayout.FlexibleSpace();

// 创建新技能按钮
if (GUILayout.Button("创建技能", EditorStyles.toolbarButton, GUILayout.Width(80)))
{
CreateNewSkill();
}

// 刷新按钮
if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(60)))
{
AssetDatabase.Refresh();
}

// 导出所有按钮
if (GUILayout.Button("导出 Flat", EditorStyles.toolbarButton, GUILayout.Width(80)))
{
ExportAllToFlatBuffer();
}

EditorGUILayout.EndHorizontal();
}

private void DrawSkillList()
{
EditorGUILayout.BeginVertical(GUILayout.Width(250));

EditorGUILayout.LabelField("技能列表", EditorStyles.boldLabel);
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);

// 查找所有技能配置
var skills = AssetDatabase.FindAssets("t:SkillConfig")
.Select(AssetDatabase.GUIDToAssetPath)
.Select(AssetDatabase.LoadAssetAtPath<SkillConfig>)
.Where(s => s != null)
.ToList();

foreach (var skill in skills)
{
// 搜索过滤
if (!string.IsNullOrEmpty(searchFilter))
{
if (!skill.skillName.ToLower().Contains(searchFilter.ToLower()) &&
!skill.skillId.ToString().Contains(searchFilter))
{
continue;
}
}

// 绘制技能项
bool isSelected = selectedSkill == skill;
if (DrawSkillListItem(skill, isSelected))
{
selectedSkill = skill;
}
}

EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
}

private bool DrawSkillListItem(SkillConfig skill, bool isSelected)
{
EditorGUILayout.BeginHorizontal(isSelected ? EditorStyles.toolbarButton : GUIStyle.none);

// 技能图标
if (skill.icon != null)
{
Texture iconTexture = skill.icon.texture;
if (iconTexture != null)
{
GUILayout.Label(iconTexture, GUILayout.Width(32), GUILayout.Height(32));
}
}
else
{
GUILayout.Box(GUIContent.none, GUILayout.Width(32), GUILayout.Height(32));
}

// 技能名称和ID
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField(skill.skillName, EditorStyles.boldLabel);
EditorGUILayout.LabelField($"ID: {skill.skillId}", EditorStyles.miniLabel);
EditorGUILayout.EndVertical();

EditorGUILayout.EndHorizontal();

return GUILayout.Button("", GUIStyle.none, GUILayout.Height(1));
}

private void DrawSkillDetail()
{
EditorGUILayout.BeginVertical();

// 技能标题
EditorGUILayout.LabelField(selectedSkill.skillName, EditorStyles.largeLabel);
EditorGUILayout.Space();

// 使用自定义编辑器
Editor editor = Editor.CreateEditor(selectedSkill);
editor.OnInspectorGUI();
Object.DestroyImmediate(editor);

EditorGUILayout.Space();

// 操作按钮
DrawSkillActions();

EditorGUILayout.EndVertical();
}

private void DrawSkillActions()
{
EditorGUILayout.BeginHorizontal();

if (GUILayout.Button("保存"))
{
EditorUtility.SetDirty(selectedSkill);
AssetDatabase.SaveAssets();
}

if (GUILayout.Button("导出 Flat"))
{
ExportToFlatBuffer(selectedSkill);
}

if (GUILayout.Button("删除"))
{
if (EditorUtility.DisplayDialog("确认", "确定要删除这个技能吗?", "确定", "取消"))
{
string path = AssetDatabase.GetAssetPath(selectedSkill);
AssetDatabase.DeleteAsset(path);
selectedSkill = null;
}
}

EditorGUILayout.EndHorizontal();
}

private void CreateNewSkill()
{
string path = EditorUtility.SaveFilePanelInProject(
"创建新技能",
"Assets/Resources/Skills",
"asset",
"请选择保存位置"
);

if (!string.IsNullOrEmpty(path))
{
// 确保 .asset 扩展名
if (!path.EndsWith(".asset"))
{
path += ".asset";
}

SkillConfig newSkill = ScriptableObject.CreateInstance<SkillConfig>();
newSkill.skillId = Random.Range(1000, 9999);
newSkill.skillName = "新技能";

AssetDatabase.CreateAsset(newSkill, path);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();

selectedSkill = newSkill;
}
}
}

四、FlatBuffers 序列化

4.1 定义 FlatBuffers Schema

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Skill.fbs

namespace Game;

enum SkillType : byte {
Passive = 0,
Active = 1,
Channel = 2,
Toggle = 3
}

enum EffectType : byte {
Damage = 0,
Heal = 1,
Buff = 2,
Debuff = 3,
Knockback = 4,
Stun = 5
}

table SkillEffect {
effectType: EffectType;
value: float;
duration: float;
targetTag: string;
}

table SkillCondition {
conditionType: string;
minValue: float;
maxValue: float;
}

table Skill {
id: int;
name: string;
type: SkillType;
damage: int;
range: float;
cooldown: float;
effects: [SkillEffect];
conditions: [SkillCondition];
animationTrigger: string;
animationDuration: float;
}

root_type Skill;

4.2 序列化/反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
using FlatBuffers;
using Game;

public static class SkillFlatBuffersSerializer
{
/// <summary>
/// 将 SkillConfig 序列化为 FlatBuffers 字节数组
/// </summary>
public static byte[] Serialize(SkillConfig skill)
{
FlatBufferBuilder builder = new FlatBufferBuilder(1024);

// 序列化效果列表
VectorOffset effectsOffset = default;
if (skill.effects != null && skill.effects.Length > 0)
{
var effectOffsets = new Offset<SkillEffect>[skill.effects.Length];
for (int i = skill.effects.Length - 1; i >= 0; i--)
{
var effect = skill.effects[i];
var targetTagOffset = builder.CreateString(effect.targetTag ?? "");

SkillEffect.StartSkillEffect(builder);
SkillEffect.AddEffectType((EffectType)effect.type);
SkillEffect.AddValue(effect.value);
SkillEffect.AddDuration(effect.duration);
SkillEffect.AddTargetTag(targetTagOffset);
effectOffsets[i] = SkillEffect.EndSkillEffect(builder);
}

var effectsVectorOffset = builder.CreateVectorOfTables(effectOffsets);
Skill.StartEffectsVector(builder, skill.effects.Length);
effectsOffset = builder.EndVector();
}

// 序列化条件列表
VectorOffset conditionsOffset = default;
if (skill.conditions != null && skill.conditions.Length > 0)
{
var conditionOffsets = new Offset<SkillCondition>[skill.conditions.Length];
for (int i = skill.conditions.Length - 1; i >= 0; i--)
{
var condition = skill.conditions[i];
var typeOffset = builder.CreateString(condition.type.ToString());

SkillConditionStart(builder);
SkillConditionAddConditionType(typeOffset);
SkillConditionAddMinValue(condition.minValue);
SkillConditionAddMaxValue(condition.maxValue);
conditionOffsets[i] = SkillConditionEnd(builder);
}

conditionsOffset = builder.CreateVectorOfTables(conditionOffsets);
}

// 序列化技能基本信息
var nameOffset = builder.CreateString(skill.skillName);
var animTriggerOffset = builder.CreateString(skill.animationTrigger ?? "");

SkillStart(builder);
SkillAddId(builder, skill.skillId);
SkillAddName(builder, nameOffset);
SkillAddType(builder, (SkillType)skill.skillType);
SkillAddDamage(builder, skill.damage);
SkillAddRange(builder, skill.range);
SkillAddCooldown(builder, skill.cooldown);
SkillAddAnimationTrigger(builder, animTriggerOffset);
SkillAddAnimationDuration(builder, skill.animationDuration);

if (effectsOffset.Value != 0)
{
SkillAddEffects(builder, effectsOffset.Value);
}

if (conditionsOffset.Value != 0)
{
SkillAddConditions(builder, conditionsOffset.Value);
}

var skillOffset = SkillEnd(builder);
builder.Finish(skillOffset.Value);

return builder.SizedByteArray();
}

/// <summary>
/// 从 FlatBuffers 字节数组反序列化为 SkillConfig
/// </summary>
public static SkillConfig Deserialize(byte[] data)
{
var buf = new ByteBuffer(data);
var skill = Skill.GetRootAsSkill(buf);

var config = ScriptableObject.CreateInstance<SkillConfig>();
config.skillId = skill.Id;
config.skillName = skill.Name;
config.skillType = (SkillType)skill.Type;
config.damage = skill.Damage;
config.range = skill.Range;
config.cooldown = skill.Cooldown;
config.animationTrigger = skill.AnimationTrigger;
config.animationDuration = skill.AnimationDuration;

// 反序列化效果
if (skill.EffectsLength > 0)
{
config.effects = new SkillEffect[skill.EffectsLength];
for (int i = 0; i < skill.EffectsLength; i++)
{
var effectData = skill.Effects(i).Value;
config.effects[i] = new SkillEffect
{
type = (EffectType)effectData.EffectType,
value = effectData.Value,
duration = effectData.Duration,
targetTag = effectData.TargetTag
};
}
}

// 反序列化条件
if (skill.ConditionsLength > 0)
{
config.conditions = new SkillCondition[skill.ConditionsLength];
for (int i = 0; i < skill.ConditionsLength; i++)
{
var conditionData = skill.Conditions(i).Value;
config.conditions[i] = new SkillCondition
{
type = (ConditionType)System.Enum.Parse(typeof(ConditionType), conditionData.ConditionType),
minValue = conditionData.MinValue,
maxValue = conditionData.MaxValue
};
}
}

return config;
}
}

4.3 导出功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public partial class SkillManagerWindow
{
private void ExportToFlatBuffer(SkillConfig skill)
{
if (skill == null) return;

byte[] data = SkillFlatBuffersSerializer.Serialize(skill);

// 保存到文件
string path = EditorUtility.SaveFilePanel(
"导出技能数据",
Application.dataPath,
"bytes"
);

if (!string.IsNullOrEmpty(path))
{
File.WriteAllBytes(path, data);
Debug.Log($"技能已导出到: {path}");
}
}

private void ExportAllToFlatBuffer()
{
// 查找所有技能
var skills = AssetDatabase.FindAssets("t:SkillConfig")
.Select(AssetDatabase.GUIDToAssetPath)
.Select(AssetDatabase.LoadAssetAtPath<SkillConfig>)
.Where(s => s != null)
.OrderBy(s => s.skillId)
.ToArray();

if (skills.Length == 0)
{
EditorUtility.DisplayDialog("提示", "没有找到技能配置", "确定");
return;
}

// 构建包含所有技能的 FlatBuffer
FlatBufferBuilder builder = new FlatBufferBuilder(4096);

var skillOffsets = new Offset<Game.Skill>[skills.Length];
for (int i = skills.Length - 1; i >= 0; i--)
{
var skillData = ConvertToFlatBufferSkill(builder, skills[i]);
skillOffsets[i] = skillData.Offset;
}

var skillsVector = builder.CreateVectorOfTables(skillOffsets);

// 创建技能集
var skillSetName = builder.CreateString("AllSkills");
SkillSetStart(builder);
SkillSetAddName(builder, skillSetName);
SkillSetAddSkills(builder, skillsVector);
var skillSetOffset = SkillSetEnd(builder);

builder.Finish(skillSetOffset.Value);

// 保存文件
string path = EditorUtility.SaveFilePanelInProject(
"导出技能数据",
"Assets/Resources",
"bytes",
"请选择保存位置"
);

if (!string.IsNullOrEmpty(path))
{
if (!path.EndsWith(".bytes"))
{
path += ".bytes";
}

File.WriteAllBytes(path, builder.SizedByteArray());
AssetDatabase.Refresh();
Debug.Log($"已导出 {skills.Length} 个技能到: {path}");
}
}

private Game.Skill ConvertToFlatBufferSkill(FlatBufferBuilder builder, SkillConfig config)
{
// 转换逻辑(与序列化部分类似)
// 这里省略详细实现
return default;
}
}

五、运行时加载

5.1 技能缓存管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
using UnityEngine;
using System.Collections.Generic;

/// <summary>
/// 技能缓存管理器 - 单例
/// </summary>
public class SkillCacheManager : MonoBehaviour
{
private static SkillCacheManager instance;
public static SkillCacheManager Instance => instance;

// 技能数据库
private SkillDatabase skillDatabase;

// 运行时缓存
private Dictionary<int, SkillRuntimeData> skillCache = new Dictionary<int, SkillRuntimeData>();

[Header("加载设置")]
public string skillDataPath = "Skills/skills.bytes";
public bool loadOnAwake = true;

void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}

void Start()
{
if (loadOnAwake)
{
LoadAllSkills();
}
}

/// <summary>
/// 加载所有技能数据
/// </summary>
public void LoadAllSkills()
{
TextAsset asset = Resources.Load<TextAsset>(skillDataPath.Replace(".bytes", ""));
if (asset == null)
{
Debug.LogError($"技能数据文件未找到: {skillDataPath}");
return;
}

byte[] data = System.Text.Encoding.UTF8.GetBytes(asset.text);
skillDatabase = new SkillDatabase(data);

Debug.Log($"已加载 {skillDatabase.Count} 个技能");
}

/// <summary>
/// 获取技能运行时数据
/// </summary>
public SkillRuntimeData GetSkill(int skillId)
{
// 检查缓存
if (skillCache.TryGetValue(skillId, out SkillRuntimeData data))
{
return data;
}

// 从数据库加载
if (skillDatabase != null)
{
ref var flatData = ref skillDatabase.GetSkill(skillId);

// 创建运行时数据
data = new SkillRuntimeData(flatData, skillDatabase);
skillCache[skillId] = data;

return data;
}

Debug.LogWarning($"技能 {skillId} 未找到");
return null;
}

/// <summary>
/// 预加载技能
/// </summary>
public void PreloadSkills(int[] skillIds)
{
foreach (int id in skillIds)
{
GetSkill(id);
}
}

/// <summary>
/// 清除缓存
/// </summary>
public void ClearCache()
{
skillCache.Clear();
System.GC.Collect();
}
}

5.2 技能运行时数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/// <summary>
/// 技能运行时数据
/// </summary>
public class SkillRuntimeData
{
public int SkillId { get; private set; }
public SkillType SkillType { get; private set; }
public int Damage { get; private set; }
public float Range { get; private set; }
public float Cooldown { get; private set; }
public float AnimationDuration { get; private set; }

private SkillEffectRuntimeData[] effects;

public SkillRuntimeData(SkillFlatData flatData, SkillDatabase database)
{
SkillId = flatData.skillId;
SkillType = (SkillType)flatData.skillType;
Damage = flatData.damage;
Range = flatData.range / 100f;
Cooldown = flatData.cooldown / 100f;
AnimationDuration = flatData.animationDuration / 100f;

// 加载效果数据
var effectsSpan = database.GetEffects(flatData);
effects = new SkillEffectRuntimeData[flatData.effectCount];
for (int i = 0; i < effects.Length; i++)
{
effects[i] = new SkillEffectRuntimeData(effectsSpan[i]);
}
}

/// <summary>
/// 获取所有效果
/// </summary>
public ReadOnlySpan<SkillEffectRuntimeData> GetEffects()
{
return effects;
}
}

/// <summary>
/// 技能效果运行时数据
/// </summary>
public struct SkillEffectRuntimeData
{
public EffectType Type { get; private set; }
public float Value { get; private set; }
public float Duration { get; private set; }
public string TargetTag { get; private set; }

public SkillEffectRuntimeData(SkillEffectFlatData flatData)
{
Type = (EffectType)flatData.effectType;
Value = flatData.value / 100f;
Duration = flatData.duration / 100f;
TargetTag = GetTagString(flatData.targetTagId);
}

private string GetTagString(int tagId)
{
// 从标签表获取
return "Enemy";
}
}

六、性能对比

6.1 加载性能测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using UnityEngine;
using UnityEditor;
using System.Diagnostics;

public class SkillPerformanceTest
{
[MenuItem("Tools/Test Skill Loading Performance")]
public static void TestLoadingPerformance()
{
const int skillCount = 1000;

// 测试 ScriptableObject 加载
var sw1 = Stopwatch.StartNew();
var scriptableSkills = new SkillConfig[skillCount];
for (int i = 0; i < skillCount; i++)
{
scriptableSkills[i] = ScriptableObject.CreateInstance<SkillConfig>();
// 模拟加载...
}
sw1.Stop();

// 测试 FlatBuffers 加载
var sw2 = Stopwatch.StartNew();
// 模拟加载 FlatBuffer 数据...
sw2.Stop();

UnityEngine.Debug.Log($"ScriptableObject 加载 {skillCount} 个技能: {sw1.ElapsedMilliseconds}ms");
UnityEngine.Debug.Log($"FlatBuffers 加载 {skillCount} 个技能: {sw2.ElapsedMilliseconds}ms");
UnityEngine.Debug.Log($"性能提升: {(float)sw1.ElapsedMilliseconds / sw2.ElapsedMilliseconds:F2}x");
}
}

6.2 内存占用对比

数据类型 1000个技能 单个技能平均
ScriptableObject ~50MB ~50KB
FlatBuffers ~5MB ~5KB
自定义二进制 ~2MB ~2KB

七、总结

本文介绍了技能编辑器与扁平数据缓存的核心要点:

主题 要点
数据结构 扁平数据比对象数据更紧凑
FlatBuffers 高效的二进制序列化格式
编辑器 ScriptableObject + CustomEditor
缓存管理 单例管理器 + 运行时缓存
性能优化 减少序列化开销和内存占用
数据导出 编辑器 → FlatBuffers → 运行时

💡 开发建议

  • 编辑期使用 ScriptableObject 便于调试
  • 运行时使用 FlatBuffers 提升性能
  • 实现增量更新,只加载必要数据
  • 考虑异步加载大量技能数据
  • 使用对象池管理技能实例

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1487842110@qq.com