Compare commits

..

10 Commits

Author SHA1 Message Date
Oliver 19dffd6c7f add license 2026-05-19 14:15:55 +08:00
Oliver 20b0f65e6a feat: 稳健性提升
现在会校验枚举名是否合法并转换为PascalCase
2026-05-19 14:12:40 +08:00
Oliver 79e6cd2a53 new: 让LLM了解这个项目 2026-05-19 12:20:59 +08:00
Oliver 493ecfeb11 feat: 跳过没有ID的行 2026-05-19 12:20:42 +08:00
Oliver b0afff73ac remove unused packages 2026-04-20 15:07:18 +08:00
Oliver fb7beedbad feat: refactor audio generation and improve Excel handling
- Add GenAudioConsts.cs to generate AudioConsts.cs with Cues, NameDictionaries, and Parameters
- Remove GenEnums.cs and its enum generation functions
- Update GenModels.cs to remove AudioObjectDefinitions generation
- Modify ExcelHelper.cs to improve numeric and formula cell handling
- Update Program.cs to use GenAudioConsts for audio constants generation
- Update README.md with improved validation checks and CueSheet generation status
2026-04-17 16:05:22 +08:00
Oliver 6f2cc57eac refactor: change delimiter from comma to pipe in Excel parsing and remove unused export format enum 2026-04-15 21:07:17 +08:00
Oliver 965998b56f feat(refactor): introduce configuration-based Excel table processing
- Replace command-line options with single tables file configuration
- Add TablesConfig class for parsing table configuration from Excel
- Add TableEntry class for storing table configuration data
- Change GetCellValue method visibility from static to internal static
- Simplify Program.cs command line interface with single --tables option
- Add error handling for missing files and directories
- Update README with new usage instructions and examples
- Remove deprecated command-line option descriptions
2026-04-13 12:37:16 +08:00
Oliver 0f32860526 refactor: improve SharedIdNames generation logic in GenModels.cs
- Refactored SharedIdNames generation to use a HashSet for collecting shared names
- Updated loop to iterate over KeyValuePair for clarity
- Added inline comments for better code understanding
- Simplified list initialization syntax
- Improved type annotations in foreach loops
2026-04-02 14:28:23 +08:00
Oliver 424178719b [partial] 支持使用AudioClipName 查询 2026-03-27 21:02:29 +08:00
15 changed files with 643 additions and 231 deletions
+66
View File
@@ -0,0 +1,66 @@
# AGENTS.md
## Build & Run
```bash
dotnet build # .NET 10.0 SDK (pinned in global.json)
dotnet run -- --tables path/to/__tables__.xlsx # pass tables config
```
The tool is a single-project console app (`ExcelTool.csproj``ExcelTool.sln`). No tests, no CI.
## Excel Sheet Convention
| Row (0-indexed) | Purpose |
|---|---|
| 0 | Field names |
| 1 | Field types (case-insensitive) |
| 2 | Field descriptions (→ XML doc comments) |
| 5+ | Data rows |
- Sheets whose name starts with `#` are skipped entirely.
- The sheet named `__enums__` is parsed specially — not as a data table but as enum definitions.
- Empty data rows are skipped automatically.
## The `__tables__.xlsx` Master Config
Column layout (row 0-3 are ignored; data starts at row 4, 0-indexed):
| Col | Field | Example |
|---|---|---|
| A | Input .xlsx path | `AudioConfigs.xlsx` |
| B | Namespace | `OCES.Audio` |
| C | Output code dir | `../hola_unity/Assets/Src/Config/` |
| D | Output data dir | `../hola_unity/Assets/Resources/Config/` |
Paths in the config are relative to the directory containing `__tables__.xlsx` unless rooted.
## Type System
All supported types are registered in `TypeRegistry.cs`. To add a new type, add a `Register(...)` call in `RegisterAll()`. The registry drives both binary serialization and C# code generation — one change fixes both.
Supported types: `int`, `uint`, `short`, `ushort`, `sbyte`, `byte`, `float`, `double`, `long`, `bool`, `string`, `vector` (→ `List<float>`, 3 components), `vectorlist` (→ `List<List<float>>`), `xxxlist` (e.g., `intlist`), `list<T>` generic syntax.
Any type **not** in the registry is treated as an enum and serialized as a single `byte`.
## Output
Per parsed sheet, three artifacts are generated:
1. `{SheetName}.cs` — data model class + `{SheetName}Config` container (both `partial`, implementing `IBinarySerializable`)
2. `AudioConsts.cs` — enums/Cues/NameDictionaries (only if `AudioObject` sheet or `__enums__` sheet exists)
3. `{SheetName}.bytes` — binary serialized data for Unity `Resources.Load<TextAsset>`
The first field of every data sheet is assumed to be `Id` (used as dictionary key in `*Config.QueryById()`).
## Key Files
| File | Role |
|---|---|
| `Program.cs` | CLI entrypoint (`--tables` option) |
| `TablesConfig.cs` | Parses `__tables__.xlsx` into `TableEntry` list |
| `ExcelHelper.cs` | Parses individual .xlsx → `ParsedSheet` / `ParsedEnum` |
| `TypeRegistry.cs` | Central type registry (binary write + codegen) |
| `Parser/GenModels.cs` | Generates `{SheetName}.cs` |
| `Parser/GenAudioConsts.cs` | Generates `AudioConsts.cs` |
| `Parser/TableExcelExportBytes.cs` | Generates `.bytes` files |
| `FileManager.cs` | File I/O utilities |
+8 -3
View File
@@ -2,6 +2,7 @@
using NPOI.XSSF.UserModel; using NPOI.XSSF.UserModel;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using ExcelTool.Parser; using ExcelTool.Parser;
@@ -82,6 +83,10 @@ namespace ExcelTool
IRow row = sheet.GetRow(i); IRow row = sheet.GetRow(i);
if (row == null) continue; if (row == null) continue;
// ID为空则跳过该行
if (string.IsNullOrEmpty(GetCellValue(row.GetCell(0)))) continue;
TableExcelRow tableExcelRow = new(); TableExcelRow tableExcelRow = new();
for (int j = 0; j < headers.Count; j++) for (int j = 0; j < headers.Count; j++)
tableExcelRow.Add(GetCellValue(row.GetCell(j))); tableExcelRow.Add(GetCellValue(row.GetCell(j)));
@@ -147,7 +152,7 @@ namespace ExcelTool
return result; return result;
} }
static string GetCellValue(ICell cell) internal static string GetCellValue(ICell cell)
{ {
if (cell == null) if (cell == null)
return ""; return "";
@@ -158,13 +163,13 @@ namespace ExcelTool
return cell.StringCellValue; return cell.StringCellValue;
case CellType.Numeric: case CellType.Numeric:
return DateUtil.IsCellDateFormatted(cell) ? cell.DateCellValue.ToString() : cell.NumericCellValue.ToString(); return DateUtil.IsCellDateFormatted(cell) ? cell.DateCellValue.ToString() : cell.NumericCellValue.ToString(CultureInfo.InvariantCulture);
case CellType.Boolean: case CellType.Boolean:
return cell.BooleanCellValue ? "1" : "0"; return cell.BooleanCellValue ? "1" : "0";
case CellType.Formula: case CellType.Formula:
return cell.ToString(); return cell.StringCellValue;
default: default:
return ""; return "";
+1 -2
View File
@@ -12,8 +12,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NPOI" Version="2.7.6" /> <PackageReference Include="NPOI" Version="2.7.6" />
<PackageReference Include="Spire.XLS" Version="12.7.1" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" /> <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" /> <PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -1
View File
@@ -91,7 +91,7 @@ namespace ExcelTool
return; return;
} }
var parts = value.Split(','); var parts = value.Split('|');
bw.Write(parts.Length); bw.Write(parts.Length);
foreach (var p in parts) foreach (var p in parts)
elemDesc.WriteBinary(bw, p); elemDesc.WriteBinary(bw, p);
+267
View File
@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace ExcelTool.Parser
{
public static class GenAudioConsts
{
public static bool Gen(
List<ParsedSheet> parsedSheets,
List<ParsedEnum> enumSheets,
string outputDir,
string nameSpace = "")
{
try
{
ParsedSheet audioSheet = parsedSheets?.Find(s => s.SheetName == "AudioObject");
bool hasAudioObject = audioSheet != null;
bool hasEnums = enumSheets is { Count: > 0 };
if (!hasAudioObject && !hasEnums) return true;
// 根据是否有 namespace 决定顶层缩进
string i1 = string.IsNullOrEmpty(nameSpace) ? "" : "\t"; // class 级
string i2 = i1 + "\t"; // class 成员级
string i3 = i2 + "\t"; // 嵌套 class 成员级
StringBuilder sb = new();
sb.Append("/*\n * auto generated by tools(注意:千万不要手动修改本文件)\n * AudioConsts\n */\n\n");
sb.Append("using System.Collections.Generic;\nusing System.Diagnostics.CodeAnalysis;\n\n");
if (!string.IsNullOrEmpty(nameSpace))
sb.Append($"namespace {nameSpace}\n{{\n");
// ── CUES ──────────────────────────────────────────────────────────
if (hasAudioObject)
{
AppendCues(sb, audioSheet, i1, i2);
sb.Append('\n');
}
// ── NAME_DICTIONARIES ─────────────────────────────────────────────
if (hasAudioObject)
{
AppendNameDictionaries(sb, audioSheet, i1, i2, i3);
sb.Append('\n');
}
// ── PARAMETERS ────────────────────────────────────────────────────
if (hasEnums)
{
AppendParameters(sb, enumSheets, i1, i2, i3);
}
if (!string.IsNullOrEmpty(nameSpace))
sb.Append("}\n");
FileManager.WriteToFile(Path.Combine(outputDir, "AudioConsts.cs"), sb.ToString());
return true;
}
catch (Exception ex)
{
ex.ToString().WriteErrorLine();
return false;
}
}
// ──────────────────────────────────────────────────────────────────────────
// CUES
// ──────────────────────────────────────────────────────────────────────────
private static void AppendCues(StringBuilder sb, ParsedSheet audioSheet, string i1, string i2)
{
int idIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "Id");
int cueNameIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "CueName");
// 如果没有 CueName 字段,回退到 Name 字段(向后兼容)
if (cueNameIndex < 0)
{
cueNameIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "Name");
if (cueNameIndex < 0)
{
// 两个字段都没有,跳过 CUES 生成
sb.Append($"{i1}public class Cues\n{i1}{{\n");
sb.Append($"{i1}}} //public class Cues\n");
return;
}
}
Dictionary<string, uint> cueNameToMinId = BuildNameToMinId(audioSheet, idIndex, cueNameIndex);
sb.AppendLine($"{i1}[SuppressMessage(\"ReSharper\", \"InconsistentNaming\")]");
sb.Append($"{i1}public class Cues\n{i1}{{\n");
foreach (KeyValuePair<string, uint> kv in cueNameToMinId)
{
string fieldName = SanitizeIdentifier(kv.Key);
sb.Append($"{i2}public static uint {fieldName} = {kv.Value};\n");
}
sb.Append($"{i1}}} //public class Cues\n");
}
// ──────────────────────────────────────────────────────────────────────────
// NAME_DICTIONARIES
// ──────────────────────────────────────────────────────────────────────────
private static void AppendNameDictionaries(
StringBuilder sb, ParsedSheet audioSheet,
string i1, string i2, string i3)
{
int idIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "Id");
int namesIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "Name");
Dictionary<uint, List<string>> idToNames = new();
Dictionary<string, List<uint>> nameToIds = new();
foreach (TableExcelRow row in audioSheet.Data.Rows)
{
if (idIndex < 0 || namesIndex < 0) continue;
if (string.IsNullOrEmpty(row.StrList[idIndex])) continue;
uint id = Convert.ToUInt32(row.StrList[idIndex]);
if (id == 0) continue; // 跳过ID=0的行
string rawNames = row.StrList[namesIndex];
List<string> names = new(rawNames.Split(['|'], StringSplitOptions.RemoveEmptyEntries));
idToNames[id] = names;
foreach (string name in names)
{
if (!nameToIds.TryGetValue(name, out List<uint> list))
{
list = [];
nameToIds[name] = list;
}
list.Add(id);
}
}
sb.AppendLine($"{i1}[SuppressMessage(\"ReSharper\", \"ClassNeverInstantiated.Global\")]");
sb.Append($"{i1}public class NameDictionaries\n{i1}{{\n");
// NameToId
sb.Append($"{i2}public static readonly Dictionary<string, uint> NameToId = new()\n{i2}{{\n");
foreach (var kv in nameToIds)
{
uint minId = uint.MaxValue;
foreach (uint id in kv.Value)
if (id < minId) minId = id;
sb.Append($"{i3}{{ \"{kv.Key}\", {minId} }},\n");
}
sb.Append($"{i2}}};\n\n");
// AmbiguousNames
sb.Append($"{i2}public static readonly HashSet<string> AmbiguousNames = new()\n{i2}{{\n");
foreach (var kv in nameToIds)
if (kv.Value.Count > 1)
sb.Append($"{i3}\"{kv.Key}\",\n");
sb.Append($"{i2}}};\n\n");
// SharedIdNames
HashSet<string> sharedNames = [];
foreach (var kv in idToNames)
if (kv.Value.Count > 1)
foreach (string name in kv.Value)
sharedNames.Add(name);
sb.Append($"{i2}public static readonly HashSet<string> SharedIdNames = new()\n{i2}{{\n");
foreach (string name in sharedNames)
sb.Append($"{i3}\"{name}\",\n");
sb.Append($"{i2}}};\n");
sb.Append($"{i1}}} //public class NameDictionaries\n");
}
// ──────────────────────────────────────────────────────────────────────────
// PARAMETERS
// ──────────────────────────────────────────────────────────────────────────
private static void AppendParameters(
StringBuilder sb, List<ParsedEnum> enumSheets,
string i1, string i2, string i3)
{
sb.AppendLine($"{i1}[SuppressMessage(\"ReSharper\", \"ClassNeverInstantiated.Global\")]");
sb.Append($"{i1}public class Parameters\n{i1}{{\n");
// EnumIds 嵌套静态类
sb.Append($"{i2}public static class EnumIds\n{i2}{{\n");
foreach (ParsedEnum parsedEnum in enumSheets)
sb.Append($"{i3}public const uint {parsedEnum.EnumName}Id = {parsedEnum.Id};\n\n");
sb.Append($"\n{i3}public static void RegisterAllGameState()\n{i3}{{\n");
foreach (ParsedEnum parsedEnum in enumSheets)
sb.Append($"{i3}\tStateGroupRegistry.Register<{parsedEnum.EnumName}>({parsedEnum.Id});\n");
sb.Append($"{i3}}}\n");
sb.Append($"{i2}}}\n\n");
// enum 定义
foreach (ParsedEnum parsedEnum in enumSheets)
{
sb.Append($"{i2}public enum {parsedEnum.EnumName}\n{i2}{{\n");
foreach (ParsedEnumMember member in parsedEnum.Members)
{
string valuePart = member.Value.HasValue ? $" = {member.Value.Value}" : "";
string descPart = string.IsNullOrEmpty(member.Desc) ? "" : $" // {member.Desc}";
sb.Append($"{i3}{member.Key}{valuePart},{descPart}\n");
}
sb.Append($"{i2}}}\n\n");
}
sb.Append($"{i1}}} //public class Parameters\n");
}
// ──────────────────────────────────────────────────────────────────────────
// 工具方法
// ──────────────────────────────────────────────────────────────────────────
/// <summary>遍历 AudioObject 行,构建 name -> minId 映射(处理资源复用)</summary>
private static Dictionary<string, uint> BuildNameToMinId(
ParsedSheet audioSheet, int idIndex, int namesIndex)
{
Dictionary<string, List<uint>> nameToIds = new();
foreach (TableExcelRow row in audioSheet.Data.Rows)
{
if (idIndex < 0 || namesIndex < 0) continue;
if (string.IsNullOrEmpty(row.StrList[idIndex])) continue;
uint id = Convert.ToUInt32(row.StrList[idIndex]);
if (id == 0) continue; // 跳过ID=0的行
string rawNames = row.StrList[namesIndex];
List<string> names = new(rawNames.Split(['|'], StringSplitOptions.RemoveEmptyEntries));
foreach (string name in names)
{
if (!nameToIds.TryGetValue(name, out List<uint> list))
{
list = [];
nameToIds[name] = list;
}
list.Add(id);
}
}
Dictionary<string, uint> result = new();
foreach (var kv in nameToIds)
{
uint minId = uint.MaxValue;
foreach (uint id in kv.Value)
if (id < minId) minId = id;
result[kv.Key] = minId;
}
return result;
}
/// <summary>将字符串中不合法的 C# 标识符字符替换为下划线</summary>
private static string SanitizeIdentifier(string name)
{
StringBuilder sb = new();
foreach (char c in name)
sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_');
if (sb.Length > 0 && char.IsDigit(sb[0]))
sb.Insert(0, '_');
return sb.ToString();
}
}
}
-83
View File
@@ -1,83 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace ExcelTool.Parser
{
public static class GenEnums
{
/// <summary>
/// 所有枚举写入同一个文件 AudioEnums.cs
/// </summary>
public static bool GenCSharpEnum(List<ParsedEnum> enumSheet, string outputDir, string nameSpace = "")
{
try
{
StringBuilder sb = new();
sb.Append("/*\n * auto generated by tools(注意:千万不要手动修改本文件)\n * AudioEnums\n */\n\n");
if (!string.IsNullOrEmpty(nameSpace))
sb.Append($"namespace {nameSpace}\n{{\n");
foreach (ParsedEnum parsedEnum in enumSheet)
{
sb.Append($"\tpublic enum {parsedEnum.EnumName}\n\t{{\n");
foreach (ParsedEnumMember member in parsedEnum.Members)
{
string valuePart = member.Value.HasValue ? $" = {member.Value.Value}" : "";
string descPart = string.IsNullOrEmpty(member.Desc) ? "" : $" // {member.Desc}";
sb.Append($"\t\t{member.Key}{valuePart},{descPart}\n");
}
sb.Append("\t}\n\n");
}
if (!string.IsNullOrEmpty(nameSpace))
sb.Append("}\n");
FileManager.WriteToFile(Path.Combine(outputDir, "AudioEnums.cs"), sb.ToString());
return true;
}
catch (Exception ex)
{
ex.ToString().WriteErrorLine();
return false;
}
}
/// <summary>
/// 将所有枚举的 Id 汇总,生成 EnumIds.cs 静态常量类
/// </summary>
public static bool GenEnumIds(List<ParsedEnum> enumSheets, string outputDir, string nameSpace = "")
{
try
{
StringBuilder sb = new();
sb.Append("/*\n * auto generated by tools(注意:千万不要手动修改本文件)\n * EnumIds\n */\n\n");
if (!string.IsNullOrEmpty(nameSpace))
sb.Append($"namespace {nameSpace}\n{{\n");
sb.Append("\tpublic static class EnumIds\n\t{\n");
foreach (ParsedEnum enumSheet in enumSheets)
sb.Append($"\t\tpublic const uint {enumSheet.EnumName} = {enumSheet.Id};\n");
sb.Append("\t}\n");
if (!string.IsNullOrEmpty(nameSpace))
sb.Append("}\n");
FileManager.WriteToFile(Path.Combine(outputDir, "AudioEnumIds.cs"), sb.ToString());
return true;
}
catch (Exception ex)
{
ex.ToString().WriteErrorLine();
return false;
}
}
}
}
+97 -1
View File
@@ -43,6 +43,7 @@ namespace ExcelTool.Parser
sb.Append("}\n"); sb.Append("}\n");
FileManager.WriteToFile(Path.Combine(outputDir, $"{sheet.SheetName}.cs"), sb.ToString()); FileManager.WriteToFile(Path.Combine(outputDir, $"{sheet.SheetName}.cs"), sb.ToString());
} }
return true; return true;
@@ -161,7 +162,7 @@ namespace ExcelTool.Parser
sb.Append($"\tList<{name}> m_{camel}InfoList;\n\n"); sb.Append($"\tList<{name}> m_{camel}InfoList;\n\n");
sb.Append($"\tpublic List<{name}> {name}List()\n\t{{\n"); sb.Append($"\tpublic List<{name}> {name}List()\n\t{{\n");
sb.Append($"\t\tthis.m_{camel}InfoList ??= new List<{name}>(m_{camel}Infos.Values);\n"); sb.Append($"\t\tthis.m_{camel}InfoList ??= new List<{name}>(this.m_{camel}Infos.Values);\n");
sb.Append($"\t\treturn this.m_{camel}InfoList;\n\t}}\n\n"); sb.Append($"\t\treturn this.m_{camel}InfoList;\n\t}}\n\n");
sb.Append($"\tpublic void DeSerialize(BinaryReader reader)\n\t{{\n"); sb.Append($"\tpublic void DeSerialize(BinaryReader reader)\n\t{{\n");
@@ -183,6 +184,101 @@ namespace ExcelTool.Parser
sb.Append("\t}\n}\n"); sb.Append("\t}\n}\n");
} }
private static void GenerateAudioObjectDefinitions(List<ParsedSheet> parsedSheets, string outputDir, string nameSpace)
{
ParsedSheet audioSheet = parsedSheets.Find(s => s.SheetName == "AudioObject");
if (audioSheet == null) return;
Dictionary<uint, List<string>> idToNames = new();
Dictionary<string, List<uint>> nameToIds = new();
int idIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "Id");
int namesIndex = audioSheet.Headers.FindIndex(h => h.FieldName == "Name");
foreach (TableExcelRow row in audioSheet.Data.Rows)
{
// 根据 Headers 找到列索引
if (idIndex < 0 || namesIndex < 0) continue;
if (string.IsNullOrEmpty(row.StrList[idIndex])) continue;
uint id = Convert.ToUInt32(row.StrList[idIndex]);
if (id == 0) continue; // 跳过ID=0的行
// Names 是用分隔符(例如 '|')拼接的字符串
string rawNames = row.StrList[namesIndex];
List<string> names = new(rawNames.Split(['|'], StringSplitOptions.RemoveEmptyEntries));
idToNames[id] = names; // 同一个ID对应了多少个不同的名字(Container)
foreach (string name in names)
{
if (!nameToIds.TryGetValue(name, out List<uint> list))
{
list = [];
nameToIds[name] = list;
}
list.Add(id);
}
}
StringBuilder sb = new();
sb.Append("/* auto generated, do not modify */\n");
sb.Append("using System.Collections.Generic;\n\n");
if (!string.IsNullOrEmpty(nameSpace))
sb.Append($"namespace {nameSpace}\n{{\n");
sb.Append("public static class AudioObjectDefinitions\n{\n");
// NameToId
sb.Append("\tpublic static readonly Dictionary<string, uint> NameToId = new()\n\t{\n");
foreach (var kv in nameToIds)
{
uint minId = uint.MaxValue;
foreach (uint id in kv.Value)
if (id < minId) minId = id;
sb.Append($"\t\t{{ \"{kv.Key}\", {minId} }},\n");
}
sb.Append("\t};\n\n");
// AmbiguousNames
sb.Append("\tpublic static readonly HashSet<string> AmbiguousNames = new()\n\t{\n");
foreach (var kv in nameToIds)
{
if (kv.Value.Count > 1)
sb.Append($"\t\t\"{kv.Key}\",\n");
}
sb.Append("\t};\n\n");
// SharedIdNames
HashSet<string> sharedNames = [];
foreach (KeyValuePair<uint, List<string>> keyValuePair in idToNames)
{
if (keyValuePair.Value.Count > 1)
{
foreach (string name in keyValuePair.Value)
sharedNames.Add(name);
}
}
sb.Append("\tpublic static readonly HashSet<string> SharedIdNames = new()\n\t{\n");
foreach (string name in sharedNames)
{
sb.Append($"\t\t\"{name}\",\n");
}
sb.Append("\t};\n");
sb.Append("}\n");
if (!string.IsNullOrEmpty(nameSpace))
sb.Append("}\n");
FileManager.WriteToFile(Path.Combine(outputDir, "AudioObjectDefinitions.cs"), sb.ToString());
}
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
// 工具方法 // 工具方法
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
+44 -5
View File
@@ -1,11 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Linq;
namespace ExcelTool.Parser namespace ExcelTool.Parser
{ {
public class ParsedEnumMember public class ParsedEnumMember
{ {
/// <summary>成员名,如 "Home"</summary> public string Key
public string Key { get; set; } {
get;
set { field = ParsedEnum.NormalizeIdentifier(value); }
}
/// <summary>显式值,为 null 时代码生成时自动递增(不写值)</summary> /// <summary>显式值,为 null 时代码生成时自动递增(不写值)</summary>
public int? Value { get; set; } public int? Value { get; set; }
@@ -19,9 +24,43 @@ namespace ExcelTool.Parser
/// <summary>A 列 Id,供外部系统通过 EnumIds 常量类引用</summary> /// <summary>A 列 Id,供外部系统通过 EnumIds 常量类引用</summary>
public uint Id { get; set; } public uint Id { get; set; }
/// <summary>枚举类型名,如 "GameState"</summary> public string EnumName
public string EnumName { get; set; } {
get;
set
{
field = NormalizeIdentifier(value);
}
}
public List<ParsedEnumMember> Members { get; set; } = new(); public static string NormalizeIdentifier(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return string.Empty;
}
string[] parts = Regex.Split(input, @"[\s_-]+")
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
for (int i = 0; i < parts.Length; i++)
{
string part = parts[i];
if (part.Length == 1)
{
parts[i] = char.ToUpperInvariant(part[0]).ToString();
}
else
{
parts[i] = char.ToUpperInvariant(part[0]) + part.Substring(1);
}
}
return string.Concat(parts);
}
public List<ParsedEnumMember> Members { get; set; } = [];
} }
} }
+1 -5
View File
@@ -4,11 +4,7 @@ namespace ExcelTool.Parser
{ {
public class TableExcelRow public class TableExcelRow
{ {
public List<string> StrList { get; set; } public List<string> StrList { get; set; } = [];
public TableExcelRow()
{
StrList = new List<string>();
}
public void Add(string str) public void Add(string str)
{ {
-29
View File
@@ -1,29 +0,0 @@
namespace ExcelTool.Parser
{
/// <summary>
/// 导出格式
/// </summary>
public enum TableExportFormat
{
/// <summary>
/// 未知
/// </summary>
Unknown = 0,
/// <summary>
/// 二进制
/// </summary>
Bytes,
/// <summary>
/// Json格式
/// </summary>
Json,
/// <summary>
/// Xml格式
/// </summary>
Xml,
/// <summary>
/// Lua格式
/// </summary>
Lua,
}
}
+48 -60
View File
@@ -12,36 +12,16 @@ namespace ExcelTool
{ {
static async Task<int> Main(string[] args) static async Task<int> Main(string[] args)
{ {
Option<DirectoryInfo> inputOption = new( Option<FileInfo> tablesOption = new(
name: "--input", name: "--tables",
description: "Excel 文件所在目录", description: "__tables__.xlsx 的路径",
getDefaultValue: () => new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory) getDefaultValue: () => new FileInfo(AppDomain.CurrentDomain.BaseDirectory)
);
Option<DirectoryInfo> outputCodeDirOption = new(
name: "--outputCodeDir",
description: "CS 模板代码输出目录"
);
Option<DirectoryInfo> outputDataDirOption = new(
name: "--outputDataDir",
description: "二进制数据输出目录"
);
Option<string> namespaceOption = new(
name: "--namespace",
description: "生成代码的命名空间",
getDefaultValue: () => ""
); );
RootCommand rootCommand = new(); RootCommand rootCommand = new();
rootCommand.AddOption(inputOption); rootCommand.AddOption(tablesOption);
rootCommand.AddOption(outputCodeDirOption);
rootCommand.AddOption(outputDataDirOption);
rootCommand.AddOption(namespaceOption);
rootCommand.SetHandler(ExcelProcess.Run, inputOption, outputCodeDirOption, outputDataDirOption, namespaceOption); rootCommand.SetHandler(ExcelProcess.Run, tablesOption);
// TODO 单元测试
return await rootCommand.InvokeAsync(args); return await rootCommand.InvokeAsync(args);
} }
@@ -49,18 +29,21 @@ namespace ExcelTool
static class ExcelProcess static class ExcelProcess
{ {
public static void Run(DirectoryInfo inputDir, DirectoryInfo? outputCodeDir, DirectoryInfo? outputDataDir, public static void Run(FileInfo tablesFile)
string nameSpace)
{ {
string path = inputDir.FullName; if(!tablesFile.Exists)
string codeDir = outputCodeDir?.FullName ?? outputDataDir?.FullName ?? path; {
string dataDir = outputDataDir?.FullName ?? outputCodeDir?.FullName ?? path; $"未在 {tablesFile} 处找到文件!".WriteErrorLine();
return;
}
DirectoryInfo dirInfo = new(path);
FileInfo[] excels = dirInfo.GetFiles("*.xlsx", SearchOption.AllDirectories); string path = tablesFile.FullName;
if (excels.Length <= 0) List<TableEntry> tableEntries = TablesConfig.Parse(path);
if (tableEntries.Count <= 0)
{ {
"当前exe目录或者目标目录没有excels文件,请重新设置目录".WriteErrorLine(); "__tables__.xlsx内没有数据".WriteErrorLine();
} }
else else
{ {
@@ -69,51 +52,56 @@ namespace ExcelTool
"== 说明:将exe放在xlsx目录中或者exe或者传入根目录 ==".WriteSuccessLine(); "== 说明:将exe放在xlsx目录中或者exe或者传入根目录 ==".WriteSuccessLine();
"==========================================================".WriteSuccessLine(); "==========================================================".WriteSuccessLine();
excels = dirInfo.GetFiles("*.xlsx", SearchOption.AllDirectories);
//读取 //读取
foreach (FileInfo file in excels) foreach (TableEntry tableEntry in tableEntries)
{ {
if (file.Name.StartsWith("~$")) continue; if (!Path.Exists(tableEntry.InputFile))
{
$"{tableEntry.InputFile} 不存在!".WriteWarningLine();
continue;
}
List<ParsedSheet> sheets = ExcelHelper.ParseAllSheets(file.FullName, out List<ParsedEnum>? enumSheets); if (!Path.Exists(tableEntry.OutputCodeDir))
{
Directory.CreateDirectory(tableEntry.OutputCodeDir);
}
if (!Path.Exists(tableEntry.OutputDataDir))
{
Directory.CreateDirectory(tableEntry.OutputDataDir);
}
string fileName = Path.GetFileName(tableEntry.InputFile);
List<ParsedSheet> sheets = ExcelHelper.ParseAllSheets(tableEntry.InputFile, out List<ParsedEnum>? enumSheets);
//生成CS文件 //生成CS文件
bool res = GenModels.GenCSharpModel(sheets, codeDir, nameSpace); bool res = GenModels.GenCSharpModel(sheets, tableEntry.OutputCodeDir, tableEntry.Namespace);
if (res) if (res)
{ {
$"{file.Name}CS模板生成成功".WriteSuccessLine(); $"{fileName}CS模板生成成功".WriteSuccessLine();
} }
else else
{ {
$"{file.Name}CS模板生成失败".WriteErrorLine(); $"{fileName}CS模板生成失败".WriteErrorLine();
} }
// 生成CS枚举文件 // 生成 AudioConsts.cs(合并枚举、枚举ID、AudioObject定义和CUE映射)
if (enumSheets.Count > 0) bool audioConstsRes = GenAudioConsts.Gen(sheets, enumSheets, tableEntry.OutputCodeDir, tableEntry.Namespace);
{ if (audioConstsRes)
bool enumRes = GenEnums.GenCSharpEnum(enumSheets, codeDir, nameSpace); $"{fileName} AudioConsts 生成成功".WriteSuccessLine();
if (enumRes)
$"{file.Name} 枚举代码生成成功".WriteSuccessLine();
else else
$"{file.Name} 枚举代码生成失败".WriteErrorLine(); $"{fileName} AudioConsts 生成失败".WriteErrorLine();
bool enumIdsRes = GenEnums.GenEnumIds(enumSheets, codeDir, nameSpace);
if (enumIdsRes)
$"{file.Name} EnumIds 生成成功".WriteSuccessLine();
else
$"{file.Name} EnumIds 生成失败".WriteErrorLine();
}
//生成二进制文件,如果list或者vector数据为空则写入0,要根据类型来读取csv的字段数据强转成对应的数据类型然后写入 //生成二进制文件,如果list或者vector数据为空则写入0,要根据类型来读取csv的字段数据强转成对应的数据类型然后写入
res = TableExcelExportBytes.ExportToFile(sheets, dataDir); res = TableExcelExportBytes.ExportToFile(sheets, tableEntry.OutputDataDir);
if (res) if (res)
{ {
$"{file.Name}二进制数据生成成功".WriteSuccessLine(); $"{fileName}二进制数据生成成功".WriteSuccessLine();
} }
else else
{ {
$"{file.Name}二进制数据生成失败".WriteErrorLine(); $"{fileName}二进制数据生成失败".WriteErrorLine();
} }
} }
} }
+68
View File
@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.IO;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
namespace ExcelTool;
public static class TablesConfig
{
internal static List<TableEntry> Parse(string tablesXlsxPath)
{
List<TableEntry> tableEntries = [];
string tablesDir = Path.GetDirectoryName(Path.GetFullPath(tablesXlsxPath))!;
try
{
using FileStream fs = File.OpenRead(tablesXlsxPath);
XSSFWorkbook workbook = new(fs);
ISheet sheet = workbook.GetSheetAt(0);
for (int i = 4; i <= sheet.LastRowNum; i++)
{
IRow row = sheet.GetRow(i);
if (row == null || string.IsNullOrEmpty(ExcelHelper.GetCellValue(row.GetCell(0)))) continue;
TableEntry tableEntry = new()
{
InputFile = ResolveDir(ExcelHelper.GetCellValue(row.GetCell(0))),
Namespace = ExcelHelper.GetCellValue(row.GetCell(1)),
OutputCodeDir = ResolveDir(ExcelHelper.GetCellValue(row.GetCell(2))),
OutputDataDir = ResolveDir(ExcelHelper.GetCellValue(row.GetCell(3))),
};
tableEntries.Add(tableEntry);
}
}
catch (Exception exception)
{
exception.ToString().WriteErrorLine();
}
return tableEntries;
string ResolveDir(string raw)
{
if (raw != null)
{
return Path.IsPathRooted(raw)
? raw
: Path.GetFullPath(Path.Combine(tablesDir, raw));
}
return null;
}
}
}
public class TableEntry
{
public string InputFile { get; init; } // e.g. "AudioConfigs.xlsx"
public string Namespace { get; init; } // e.g. "OCES.Audio"
public string OutputCodeDir { get; init; }
public string OutputDataDir { get; init; }
}
+3 -3
View File
@@ -182,7 +182,7 @@ namespace ExcelTool
WriteBinary = (bw, v) => WriteBinary = (bw, v) =>
{ {
if (string.IsNullOrEmpty(v)) { bw.Write(0); return; } if (string.IsNullOrEmpty(v)) { bw.Write(0); return; }
var parts = v.Split(','); var parts = v.Split('|');
bw.Write(parts.Length); bw.Write(parts.Length);
foreach (var p in parts) writeElem(bw, p); foreach (var p in parts) writeElem(bw, p);
}, },
@@ -226,9 +226,9 @@ namespace ExcelTool
"\t\telse\n" + "\t\telse\n" +
"\t\t{\n" + "\t\t{\n" +
$"\t\t\twriter.Write({name}.Count);\n" + $"\t\t\twriter.Write({name}.Count);\n" +
$"\t\t\tfor (int i = 0; i < {name}.Count; i++)\n" + $"\t\t\tforeach (var t in {name})\n" +
"\t\t\t{\n" + "\t\t\t{\n" +
$"\t\t\t\twriter.Write({name}[i]);\n" + $"\t\t\t\twriter.Write(t);\n" +
"\t\t\t}\n" + "\t\t\t}\n" +
"\t\t}\n"; "\t\t}\n";
} }
+13
View File
@@ -0,0 +1,13 @@
Copyright 2026 Oliver Wong
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+18 -31
View File
@@ -1,11 +1,6 @@
# ExcelTool(Unity打表工具) # ExcelTool
通用Excel打表工具
#### 前言
目前项目中用的csv转json工具,会带来严重的gc性能问题,例如启动加载慢,影响帧率等问题,转成的json数据,我们需要手动写对应的解析模板代码,所以针对以上问题,本通用打表工具应运而生,用二进制数据替代json数据会比较好的解决性能问题,而且能够自动生成对应的模板代码,节省了写重复代码的时间,而且二进制数据比json读取速度更快,文件大小更小。
本项目基于 https://github.com/dingxiaowei/ExcelTool 开发。用于 [AudioSystem](https://github.com/htw128/UnityAudioExtend), [HapticSystem](https://github.com/htw128/UnityAudioExtend#Haptic) 导出数据使用。
#### 功能 #### 功能
@@ -22,16 +17,9 @@
* 支持自定义字段 vector,例如[1,2,3] * 支持自定义字段 vector,例如[1,2,3]
* 支持自定义字段 list,其实是vector数组,例如[[1,2,3],[2,3,4],[4,5,6]] * 支持自定义字段 list,其实是vector数组,例如[[1,2,3],[2,3,4],[4,5,6]]
#### 效果
![](img/1.gif)
![](img/2.jpg)
相比较json,二进制文件大小只有其1/4
##### 自动生成的模板代码 ##### 自动生成的模板代码
``` ```c#
/* /*
* auto generated by tools(注意:千万不要手动修改本文件) * auto generated by tools(注意:千万不要手动修改本文件)
* avatarguideTest * avatarguideTest
@@ -197,7 +185,7 @@ public partial class avatarguideTestConfig : IBinarySerializable
##### 解析二进制文件 ##### 解析二进制文件
``` ```c#
IBinarySerializable newavList = new avatarguideTestList(); IBinarySerializable newavList = new avatarguideTestList();
var readOK = FileManager.ReadBinaryDataFromFile(Path.Combine(path, "avatarguideTest.dat"), ref newavList); var readOK = FileManager.ReadBinaryDataFromFile(Path.Combine(path, "avatarguideTest.dat"), ref newavList);
if (readOK) if (readOK)
@@ -216,7 +204,7 @@ else
将二进制文件(后缀必须是.bytes)放在Resources目录,然后通过Resources.Load接口加载 将二进制文件(后缀必须是.bytes)放在Resources目录,然后通过Resources.Load接口加载
``` ```c#
var bytes = Resources.Load<TextAsset>("avatarguideTest"); var bytes = Resources.Load<TextAsset>("avatarguideTest");
if (bytes == null) if (bytes == null)
{ {
@@ -242,7 +230,7 @@ else
#### 泛型读表 #### 泛型读表
``` ```c#
T ReadTable<T>(string tableName) where T : IBinarySerializable, new() T ReadTable<T>(string tableName) where T : IBinarySerializable, new()
{ {
var bytes = LoadTextAsset(tableName); var bytes = LoadTextAsset(tableName);
@@ -265,7 +253,7 @@ return default(T);
##### 查询数据 ##### 查询数据
``` ```c#
var avatarguideTest = (avatarguideTestConfig)newavList; var avatarguideTest = (avatarguideTestConfig)newavList;
var obj = avatarguideTest.QueryById(2); var obj = avatarguideTest.QueryById(2);
if (obj != null && obj.Count() > 0) if (obj != null && obj.Count() > 0)
@@ -274,7 +262,7 @@ if (obj != null && obj.Count() > 0)
#### 自动化拷贝批处理 #### 自动化拷贝批处理
``` ```batch
@echo off @echo off
echo "开始拷贝jsons" echo "开始拷贝jsons"
@@ -292,20 +280,19 @@ pause
上面的config类采用的是partical class 就是为了方便扩展自定义的方法,如果想要添加自定义方法也定义一个partical class文件作为扩展即可 上面的config类采用的是partical class 就是为了方便扩展自定义的方法,如果想要添加自定义方法也定义一个partical class文件作为扩展即可
#### 待扩展的功能 #### TODO
目前还不是最理想的状态,还有很多可以扩展完善的功能,如下:
* [x] 支持枚举类型 * [x] 支持枚举类型
* [ ] 支持生成各种类型的文件,例如json、xml、proto、lua等 * [x] 支持不同表不同namespace
* [ ] 支持生成各种语法的模板代码
* [ ] 支持Excel数据配置规范性检测,例如手误配置不符合规范导致加载异常,例如大小写逗号(肉眼容易忽略),或者空格等等 * [ ] 支持Excel数据配置规范性检测,例如手误配置不符合规范导致加载异常,例如大小写逗号(肉眼容易忽略),或者空格等等
* [ ] 支持Excel字段客户端服务器可选项 * [ ] ID不能重复 给出错误
* [ ] 支持更多自定义数据类型扩展 * [ ] Music、Audio Container不能自引用 给出错误
* [ ] 同时播放的音乐BPM必须一致 给出错误
#### 代码 * [ ] CueName不能包含空格` `,和`-`,不能以数字开头。给出警告
* [ ] Blend容器不能配置Haptic ID 给出警告
https://github.com/dingxiaowei/ExcelTool 喜欢麻烦点个star吧 * [ ] SyncPoint功能性需要两个文件BPM、采样率皆一致。给出警告
* [x] 生成CueSheet避免magic number
* [ ] AudioObject支持Container嵌套
#### Unity客户端使用范例 #### Unity客户端使用范例