工具概述

多语言文件批量对比工具是一个Unity编辑器扩展工具,用于批量扫描和对比游戏中的多语言Lua文件,检查不同语言版本之间的键值一致性。该工具能够自动识别同名语言文件组,并对比各组文件中键的差异,帮助开发者快速发现多语言文件中的缺失或多余键值。

功能特性

  • 批量扫描指定目录下的所有Lua语言文件
  • 自动按文件名分组多语言文件
  • 对比同组文件中键值的差异
  • 显示缺失键和多余键的详细报告
  • 支持嵌套表结构的键值对比
  • 提供简洁直观的GUI界面

使用说明

安装与启动

  1. 将脚本放置在Unity项目的Editor文件夹下
  2. 在Unity编辑器中,通过菜单栏选择 Tools/多语言文件批量对比 打开工具窗口

界面说明

工具窗口包含以下主要部分:

  • 路径选择:显示和修改语言文件所在目录(默认为Assets/_GameCenter/ClientLua/Model/Language
  • 扫描对比按钮:点击后开始扫描指定目录并对比文件
  • 结果展示区域:显示对比结果的详细信息

操作步骤

  1. 确认或修改语言文件目录路径
  2. 点击”Scan and Compare All Language Files”按钮
  3. 查看对比结果:
    • 文件总数和分组数统计
    • 存在差异的文件组列表
    • 每组文件中缺失或多余的键值详情

输出结果解读

  • 一致性报告All language file groups are consistent! 表示所有文件组内容一致
  • 差异报告:包含以下信息:
    • 差异文件组名称
    • 组内所有文件名
    • 与基准文件相比缺失的键
    • 与基准文件相比多余的键
    • 文件路径信息便于定位问题

技术实现

核心逻辑

  1. 文件扫描:递归查找指定目录下的所有.lua文件
  2. 文件分组:按文件名(不含扩展名)将文件分组
  3. 键值提取:使用Lua解析器读取文件内容,提取所有键(包括嵌套表键)
  4. 差异对比:以每组第一个文件为基准,对比其他文件的键集合
  5. 结果生成:汇总所有差异信息,格式化输出

关键方法

  • ExtractLuaTableKeys:解析Lua文件并提取所有键
  • ExtractKeysRecursively:递归处理嵌套表结构
  • ScanAndCompareFiles:主逻辑流程控制

注意事项

  1. 工具依赖Lua环境,首次使用会自动初始化
  2. 需要确保Lua文件路径正确且可访问
  3. 基准文件为每组中的第一个文件,对比结果以此为参照
  4. 复杂的Lua文件结构可能导致解析失败,请检查错误日志
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using LuaInterface;
using LuaFramework;
using System;

/*
* LanguageFilesBatchComparer
*
* 功能:批量对比多语言 Lua 文件中的字段差异,找出各语言版本文件之间的缺失字段与多余字段。
*
* 使用说明:
* --------------------------------------
* 1. 将该脚本放入 Unity 工程的 Editor 文件夹中。
* 2. 在 Unity 菜单栏点击 Tools > 多语言文件批量对比 打开窗口。
* 3. 默认目录为:Assets/_GameCenter/ClientLua/Model/Language,可点击“Select Folder”修改目标目录。
* 4. 点击“Scan and Compare All Language Files”按钮,工具将自动扫描该目录下所有 `.lua` 文件并进行分组对比。
*
* 工具特性:
* --------------------------------------
* - **自动扫描**:递归查找指定文件夹及其子文件夹下所有 `.lua` 文件。
* - **智能分组**:以文件名(不含扩展名)为键,对文件进行自动分组。
* - **批量对比**:每组文件内部进行字段对比,支持找出:
* - 缺失字段(参考文件有但当前文件没有)
* - 多余字段(当前文件有但参考文件没有)
* - **结果输出**:将对比结果输出至编辑器窗口,同时在控制台输出详细信息。
* - **Lua VM 支持**:内部使用 LuaState 启动 Lua 虚拟机执行文件,解析 Lua 表结构,准确性更高。
*
* 注意事项:
* --------------------------------------
* - 所有语言 Lua 文件 **必须返回一个 table**,例如:
* return {
* key1 = "内容1",
* key2 = "内容2"
* }
* - 文件名必须保持一致,才能分为一组。
* - 以 **每组的第一个文件**作为参考标准,其他文件与其对比字段差异。
* - 文件中如包含全局变量、语法错误、注释混淆等内容,可能导致 Lua 解析失败。
* - 该工具不会解析嵌套表,仅支持一级 key 的对比。
*/

public class LanguageFilesBatchComparer : EditorWindow
{
private Dictionary<string, List<LanguageFileInfo>> fileGroups = new Dictionary<string, List<LanguageFileInfo>>();
private Vector2 scrollPosition;
private string comparisonResult = "";
private static string folderPath, defaultPath = "Assets/_GameCenter/ClientLua/Model/Language";

private static LuaState lua { get; set; }
private static bool isStarted;

private class LanguageFileInfo
{
public string FileName;
public string FullPath;
public List<string> Keys;
}

private static void Initialize()
{
if (isStarted && lua != null) return;
//这句代码需要执行一下,不然lualoader初始化不成,LuaManager.inst.Start()会执行失败
var loader = ROYAL_EMPIRE_GAMES_LuaLoader.inst;
lua = new LuaState();
OpenCJson(); //必须向当前的 LuaState 注册 cjson 库,否则会报错
lua.LuaSetTop(0);
LuaBinder.Bind(lua); // 如果需要绑定C#类
//向 Lua 解释器添加 Lua 文件的搜索路径,以便在通过 DoFile 或 require 加载 Lua 文件时,能够正确找到所需的 Lua 模块或脚本。
lua.AddSearchPath(ROYAL_EMPIRE_GAMES_AppConst.ClientLuaRoot);
lua.AddSearchPath(ROYAL_EMPIRE_GAMES_AppConst.FrameworkRoot + "/ToLua/Lua");
lua.Start(); // 启动 VM
isStarted = true;
}

//cjson 比较特殊,只new了一个table,没有注册库,这里注册一下
private static void OpenCJson()
{
lua.LuaGetField(LuaIndexes.LUA_REGISTRYINDEX, "_LOADED");
lua.OpenLibs(LuaDLL.luaopen_cjson);
lua.LuaSetField(-2, "cjson");
lua.OpenLibs(LuaDLL.luaopen_cjson_safe);
lua.LuaSetField(-2, "cjson.safe");
}

[MenuItem("Tools/多语言文件批量对比")]
public static void ShowWindow()
{
GetWindow<LanguageFilesBatchComparer>("Language Files Batch Comparer");
}

private void OnGUI()
{
GUILayout.Label("Language Files Comparison Tool", EditorStyles.boldLabel);
EditorGUILayout.Space();
folderPath = string.IsNullOrEmpty(folderPath) ? defaultPath : folderPath;
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Languages Folder Path:", folderPath);
if (GUILayout.Button("...", GUILayout.Width(30)))
{
var path = EditorUtility.OpenFolderPanel("Select Languages Folder", defaultPath, "");
if (string.IsNullOrEmpty(path)) return;
// 转换为相对于Assets的路径
if (path.StartsWith(Application.dataPath))
folderPath = path.Substring(path.IndexOf("Assets"));
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
if (GUILayout.Button("Scan and Compare All Language Files"))
{
if (string.IsNullOrEmpty(folderPath) || !Directory.Exists(folderPath))
Debug.Log("Please select a valid folder path first!");
else
ScanAndCompareFiles();
}
EditorGUILayout.Space();
GUILayout.Label("Comparison Results", EditorStyles.boldLabel);
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, GUILayout.ExpandHeight(true));
EditorGUILayout.TextArea(comparisonResult, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView(); //
}

private void ScanAndCompareFiles()
{
Initialize(); // 确保已初始化
fileGroups.Clear();
comparisonResult = "";

// 获取所有Lua文件
var path = Path.GetFullPath(Path.Combine(Application.dataPath, folderPath.Replace("Assets/", "")));
var files = Directory.GetFiles(path, "*.lua", SearchOption.AllDirectories);
if (files.Length == 0)
{
comparisonResult = "No .lua files found in the specified folder!";
return;
}

// 按文件名分组(不带扩展名)
foreach (var filePath in files)
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
try
{
var keys = ExtractLuaTableKeys(filePath);
var fileInfo = new LanguageFileInfo
{
FileName = fileName,
FullPath = filePath,
Keys = keys
};
if (!fileGroups.ContainsKey(fileName)) fileGroups.Add(fileName, new List<LanguageFileInfo>());
fileGroups[fileName].Add(fileInfo);
}
catch (Exception e)
{
Debug.LogError($"Error parsing file {filePath}: {e.Message}");
}
}
lua?.Dispose();

// 对比每组同名文件
var totalIssues = 0;
foreach (var group in fileGroups)
{
if (group.Value.Count < 2)
{
comparisonResult += $"File '{group.Key}' has no counterparts to compare with.\n";
continue;
}

// 以第一个文件为基准
var referenceFile = group.Value[0];
var hasIssues = false;
var groupResult = "";
for (var i = 1; i < group.Value.Count; i++)
{
var currentFile = group.Value[i];
var missingKeys = referenceFile.Keys.Except(currentFile.Keys).ToList();
var extraKeys = currentFile.Keys.Except(referenceFile.Keys).ToList();
if (missingKeys.Count > 0 || extraKeys.Count > 0)
{
hasIssues = true;
groupResult += $"\nCompared to {referenceFile.FileName}:\n";
if (missingKeys.Count > 0)
groupResult += $"Missing keys in {currentFile.FullPath}:\n{string.Join("\n", missingKeys)}\n";
if (extraKeys.Count > 0)
groupResult += $"Extra keys in {currentFile.FullPath}:\n{string.Join("\n", extraKeys)}\n";
}
}
if (hasIssues)
{
totalIssues++;
comparisonResult += $"\n=== Differences found in '{group.Key}' group ===\n";
comparisonResult += $"Files in group: {string.Join(", ", group.Value.Select(f => f.FileName))}\n";
comparisonResult += groupResult;
}
}
comparisonResult =
$"Scanned {files.Length} files, found {fileGroups.Count} groups, {totalIssues} groups with issues.\n" +
comparisonResult;
if (totalIssues == 0) comparisonResult += "\nAll language file groups are consistent!";
}

private List<string> ExtractLuaTableKeys(string filePath)
{
var keys = new List<string>();
try
{
// 使用 DoFile 加载 Lua 文件并直接获取返回值(LuaTable)
var lanTable = lua.DoFile<LuaTable>(filePath);
if (lanTable == null)
throw new Exception($"无法加载 Lua 文件:{filePath}");

// 递归提取所有键(包括嵌套表的键)
ExtractKeysRecursively(lanTable, "", keys);

// 释放资源(可选,但建议加上)
lanTable.Dispose();
}
catch (Exception ex)
{
Debug.LogError($"解析文件 {filePath} 失败: {ex.Message}");
throw;
}
return keys;
}

private void ExtractKeysRecursively(LuaTable table, string parentKey, List<string> keys)
{
var dictTable = table.ToDictTable();
foreach (var entry in dictTable)
{
var key = entry.Key?.ToString();
if (string.IsNullOrEmpty(key)) continue;

// 构建完整键路径
var fullKey = string.IsNullOrEmpty(parentKey) ? key : $"{parentKey}.{key}";

// 如果是嵌套表,递归处理
if (entry.Value is LuaTable nestedTable)
ExtractKeysRecursively(nestedTable, fullKey, keys);
else
keys.Add(fullKey);
}
}
}