Compare commits

...

2 Commits

3 changed files with 688 additions and 859 deletions

View File

@ -1,859 +0,0 @@
# 🔧 TH1 代码优化标准
> **适用范围**: TH1 回合制4X策略游戏全模块
> **核心目标**: 减少GC分配 · 加快计算速度 · 保证输入输出不变
> **约束条件**: 不改变整体逻辑 · 兼容 MemoryPack 本地/网络序列化 · 保持多人同步正确性
---
## 目录
- [第一章 总则与核心原则](#第一章-总则与核心原则)
- [第二章 序列化安全规范](#第二章-序列化安全规范)
- [第三章 GC优化标准](#第三章-gc优化标准)
- [第四章 计算优化标准](#第四章-计算优化标准)
- [第五章 模块专项优化指南](#第五章-模块专项优化指南)
- [第六章 优化验证清单](#第六章-优化验证清单)
- [附录A 已知GC热点清单](#附录a-已知gc热点清单)
- [附录B 禁止与允许操作速查表](#附录b-禁止与允许操作速查表)
---
## 第一章 总则与核心原则
### 1.1 优化铁律
| 编号 | 规则 | 说明 |
|------|------|------|
| **R-01** | **输入输出不变** | 任何优化不得改变方法的参数签名、返回值语义和副作用。对外行为必须与优化前完全一致 |
| **R-02** | **逻辑等价** | 优化只能改变"怎么做",不能改变"做什么"。所有分支路径、计算结果、状态变更必须等价 |
| **R-03** | **序列化安全** | 任何新增缓存/查找表字段必须加 `[MemoryPackIgnore]`,不得影响存档文件和网络消息的二进制格式 |
| **R-04** | **确定性一致** | 多人联网场景下,优化后的计算必须保持确定性(相同输入产生相同输出),不得引入浮点精度差异 |
| **R-05** | **渐进式优化** | 每次只优化一个点,优化后必须验证,确认无误后再优化下一个 |
### 1.2 优化优先级
```
优先级从高到低:
P0 - 消除热路径中的 GC 分配(每帧/每回合执行的代码)
P1 - 缓存重复计算结果(空间换时间,需考虑序列化)
P2 - 算法复杂度优化O(n²) → O(n) 或 O(n log n)
P3 - 减少非热路径的 GC 分配(初始化/低频调用)
P4 - 微优化(方法内联、分支预测等)
```
### 1.3 不优化原则
以下情况**不应**进行优化:
- 仅在游戏初始化时执行一次的代码(如 `SkillFactory` 首次反射扫描)
- 仅在加载/存档时执行的代码(除非造成明显卡顿)
- UI 层的低频更新代码
- 已经足够高效且无 GC 问题的代码
---
## 第二章 序列化安全规范
### 2.1 TH1 序列化架构概览
TH1 存在三种序列化场景,任何"空间换时间"优化必须同时考虑:
| 场景 | 序列化方式 | 频率 | 敏感度 |
|------|-----------|------|--------|
| **本地存档** | MemoryPack → `.dat` 文件 | 每回合一次 | 向后兼容(旧存档能读取) |
| **网络同步** | MemoryPack → Steam P2P | 每个Action、每2秒心跳 | 双向兼容(多客户端版本) |
| **训练数据** | MemoryPack / JSON → `.jsonl` | `#if ENABLE_TRAIN` 时每步一次 | 格式稳定 |
### 2.2 核心 MemoryPackable 类清单
以下类直接参与序列化,新增字段时**必须**遵守规范:
```
MapData ─ 游戏世界根节点(包含以下所有子结构)
├── MapConfig ─ 对局配置
├── GridMapData ─ 所有格子
│ └── GridData ─ 单个格子(附带 Skills
├── PlayerMapData─ 所有玩家
│ └── PlayerData─ 单个玩家(附带 Skills, DiplomacyData, TechTreeData
├── CityMapData ─ 所有城市
│ └── CityData ─ 单个城市(附带 TerritoryData, Skills
├── UnitMapData ─ 所有部队
│ └── UnitData ─ 单个部队(附带 Skills, ActionPoint
└── NetData ─ 网络状态(含 Actions 历史, 随机种子)
CommonActionParams ─ Action参数网络传输
BaseMessage (16种) ─ 网络消息(含 GameStartMessage 携带完整 MapData
SkillBase (242种子类)─ 技能多态序列化
```
### 2.3 新增缓存字段的标准做法
#### ✅ 正确做法:`[MemoryPackIgnore]` + 重建逻辑
```csharp
[MemoryPackable]
public partial class UnitData : IdentifierBase
{
// 已有的序列化字段
public List<SkillBase> Skills;
// ===== 新增的缓存字段 =====
[MemoryPackIgnore]
private Dictionary<SkillType, SkillBase> _skillLookup;
/// <summary>
/// O(1) 技能查找,替代 Skills 列表的 O(n) 线性搜索。
/// 缓存在反序列化后自动重建。
/// </summary>
public SkillBase GetSkillFast(SkillType type)
{
if (_skillLookup == null) RebuildSkillLookup();
return _skillLookup.TryGetValue(type, out var skill) ? skill : null;
}
// 当 Skills 列表发生变更时调用
public void InvalidateSkillLookup()
{
_skillLookup = null; // 延迟重建,下次查找时自动构建
}
private void RebuildSkillLookup()
{
_skillLookup ??= new Dictionary<SkillType, SkillBase>(Skills.Count);
_skillLookup.Clear();
foreach (var skill in Skills)
_skillLookup[skill.GetSkillType()] = skill;
}
[MemoryPackOnDeserialized]
public void OnAfterDeserialize()
{
_skillLookup = null; // 反序列化后标记为需要重建
// 不在此处立即重建,延迟到首次使用
}
}
```
#### ❌ 错误做法
```csharp
// 错误1缓存字段没有加 [MemoryPackIgnore]
[MemoryPackable]
public partial class UnitData
{
public Dictionary<SkillType, SkillBase> _skillLookup; // ❌ 会被序列化,增大存档/网络包体积
}
// 错误2在 [MemoryPackOnDeserialized] 中执行重量级初始化
[MemoryPackOnDeserialized]
public void OnAfterDeserialize()
{
RebuildSkillLookup(); // ❌ 网络同步的每次反序列化都会触发
RecalcAllTerritoryBonuses(); // ❌ 高频反序列化时造成卡顿
}
// 错误3修改序列化字段的类型或移除字段
public uint LegionId; // ❌ 如果改名或移除,旧存档无法反序列化
```
### 2.4 序列化兼容性检查清单
每次涉及 `[MemoryPackable]` 类的修改,必须检查:
- [ ] 新增字段是否加了 `[MemoryPackIgnore]`
- [ ] 是否修改/删除/重排了已有的序列化字段?(**禁止**
- [ ] 缓存重建逻辑是否在 `[MemoryPackOnDeserialized]` 中以**延迟方式**处理?
- [ ] 旧存档能否正常加载MemoryPack 对新增 Ignore 字段透明)
- [ ] 多人游戏中不同客户端版本能否互通?
### 2.5 网络消息的特殊约束
对于 `CommonActionParams``BaseMessage` 子类:
- **绝不**新增序列化字段(增加每次 Action 的网络开销)
- 如需传递额外数据,使用已有字段编码(如 `Value1`-`Value4` 通用数值字段)
- 对于高频消息心跳、Action确认任何字节增长都会累积
### 2.6 反序列化重建的性能规范
```
[MemoryPackOnDeserialized] 回调中:
├── ✅ 可以做:设置标志位、置空缓存引用
├── ✅ 可以做O(1) 的简单赋值
├── ⚠️ 谨慎做O(n) 的字典/集合重建(仅在必要时)
└── ❌ 禁止做O(n²) 或更复杂的计算、涉及其他对象的查询
```
理由:网络同步时,`ForceUpdateMessage``GameStartMessage` 会反序列化完整 `MapData`,触发所有子对象的 `[MemoryPackOnDeserialized]` 回调。如果每个回调都做重量级初始化,会造成瞬间卡顿。
---
## 第三章 GC优化标准
### 3.1 集合分配优化
#### 3.1.1 复用集合替代重复创建
**问题模式**:在频繁调用的方法中 `new List<T>()` / `new HashSet<T>()` / `new Dictionary<K,V>()`
```csharp
// ❌ 每次调用都分配新集合
public List<GridData> GetAroundGridData(GridData center, int radius)
{
List<GridData> result = new List<GridData>(); // GC 分配
// ... 填充 result
return result;
}
// ✅ 方案A调用方传入缓冲区
public void GetAroundGridData(GridData center, int radius, List<GridData> buffer)
{
buffer.Clear();
// ... 填充 buffer
}
// ✅ 方案B类级别复用集合非线程安全单线程游戏逻辑可用
private static readonly List<GridData> _sharedGridBuffer = new List<GridData>(64);
public List<GridData> GetAroundGridData(GridData center, int radius)
{
_sharedGridBuffer.Clear();
// ... 填充 _sharedGridBuffer
return _sharedGridBuffer; // 注意:调用方不应持有引用或修改
}
```
**适用范围**
| 方法 | 当前问题 | 建议方案 |
|------|---------|---------|
| `GridMapData.GetAroundGridData()` (4个重载) | 每次 new List/HashSet | 传入缓冲区 |
| `GridMapData.GetAroundGridIdList()` | 每次 new List\<uint\> | 传入缓冲区 |
| `MapData.GetPlayerTerritoryGridIdSet()` | 每次 new HashSet + new List | 玩家级缓存 + 脏标记 |
| `MapData.GetUnitDataListByPlayerId()` | null 参数时 new List | 必须传入缓冲区 |
| `MapData.GetUnitDataListByCityId()` | null 参数时 new List | 必须传入缓冲区 |
| `MapData.GetCityDataListByPlayerId()` | null 参数时 new List | 必须传入缓冲区 |
#### 3.1.2 集合预分配容量
```csharp
// ❌ 默认容量,后续频繁扩容
var list = new List<UnitData>();
// ✅ 预分配合理容量
var list = new List<UnitData>(expectedCount);
// ✅ 字典也需要预分配
var dict = new Dictionary<uint, CityData>(CityList.Count);
```
**预分配容量参考**
| 集合用途 | 建议初始容量 |
|---------|-------------|
| 格子周围列表radius=1 | 9 |
| 格子周围列表radius=2 | 25 |
| 玩家城市列表 | 8 |
| 玩家部队列表 | 32 |
| 城市领土格子集合 | 64 |
| 技能列表 | 8 |
#### 3.1.3 消除默认参数创建集合
```csharp
// ❌ 可选参数导致隐式分配
public void GetUnitDataListByPlayerId(uint pid, List<UnitData> list = null)
{
if (list == null) list = new List<UnitData>(); // 隐式 GC 分配
// ...
}
// ✅ 强制调用方提供缓冲区(消除隐式分配)
public void GetUnitDataListByPlayerId(uint pid, List<UnitData> buffer)
{
buffer.Clear();
// ...
}
```
### 3.2 字符串优化
#### 3.2.1 避免热路径中的字符串操作
```csharp
// ❌ 循环中拼接字符串
foreach (var unit in units)
{
string key = "Unit_" + unit.Id.ToString(); // 每次循环产生 2 次分配
Debug.Log($"Processing {key}"); // 插值字符串再分配
}
// ✅ 调试日志使用条件编译
#if UNITY_EDITOR
foreach (var unit in units)
{
Debug.Log($"Processing Unit_{unit.Id}");
}
#endif
// ✅ 需要运行时字符串时使用 StringBuilder 复用
private static readonly StringBuilder _sb = new StringBuilder(256);
public string GetDebugInfo()
{
_sb.Clear();
_sb.Append("Unit_").Append(unit.Id);
return _sb.ToString(); // 仅产生一次分配
}
```
#### 3.2.2 枚举转字符串缓存
```csharp
// ❌ 每次调用都装箱 + 反射
string name = skillType.ToString(); // 装箱 + 内部反射查找
// ✅ 预建枚举字符串映射表
private static readonly Dictionary<SkillType, string> _skillTypeNames;
static SkillHelper()
{
var values = (SkillType[])Enum.GetValues(typeof(SkillType));
_skillTypeNames = new Dictionary<SkillType, string>(values.Length);
foreach (var v in values) _skillTypeNames[v] = v.ToString();
}
public static string GetSkillTypeName(SkillType type) => _skillTypeNames[type];
```
### 3.3 对象创建优化
#### 3.3.1 技能工厂优化SkillFactory
```csharp
// ❌ 当前:每次创建技能都用 Activator.CreateInstance反射开销大
public static SkillBase GetSkillBySkillType(SkillType type)
{
return Activator.CreateInstance(_skillDict[type]) as SkillBase; // 反射 + 装箱
}
// ✅ 使用编译时委托替代反射
private static Dictionary<SkillType, Func<SkillBase>> _skillCreators;
static void InitCreators()
{
_skillCreators = new Dictionary<SkillType, Func<SkillBase>>();
foreach (var kv in _skillDict)
{
var type = kv.Value;
// 编译一次,后续调用零反射
var constructor = type.GetConstructor(Type.EmptyTypes);
var newExp = Expression.New(constructor);
var lambda = Expression.Lambda<Func<SkillBase>>(newExp).Compile();
_skillCreators[kv.Key] = lambda;
}
}
public static SkillBase GetSkillBySkillType(SkillType type)
{
return _skillCreators[type](); // 无反射,接近 new 的性能
}
```
#### 3.3.2 避免闭包和委托分配
```csharp
// ❌ Lambda 捕获外部变量产生闭包对象
int threshold = 5;
var filtered = units.Where(u => u.Level > threshold).ToList(); // 闭包 + List 分配
// ✅ 手动循环,零分配
private static readonly List<UnitData> _tempFilteredUnits = new List<UnitData>(32);
public List<UnitData> GetUnitsAboveLevel(List<UnitData> units, int threshold)
{
_tempFilteredUnits.Clear();
for (int i = 0; i < units.Count; i++)
{
if (units[i].Level > threshold)
_tempFilteredUnits.Add(units[i]);
}
return _tempFilteredUnits;
}
```
### 3.4 LINQ 禁用规范
**在热路径中完全禁止使用 LINQ**,包括但不限于:
| 禁止操作 | 原因 | 替代方案 |
|---------|------|---------|
| `.Where().ToList()` | 分配迭代器 + 新 List | 手动 for 循环 + 预分配 List |
| `.Select().ToArray()` | 分配迭代器 + 新 Array | 手动 for 循环 + 预分配 Array |
| `.Any(predicate)` | 分配闭包对象 | 手动 for 循环 + bool 标志 |
| `.Count(predicate)` | 分配闭包对象 | 手动 for 循环 + int 计数 |
| `.First() / .FirstOrDefault()` | 分配迭代器 | 手动 for 循环 + break |
| `.OrderBy()` | 分配排序缓冲区 | 原地排序或插入排序 |
| `.ToDictionary()` | 分配新 Dictionary | 预分配 + 手动填充 |
| `.Sum() / .Max() / .Min()` | 分配迭代器 | 手动累加 |
**热路径定义**:每帧、每回合、每个 Action、AI 决策循环中执行的代码。
**非热路径**(允许使用 LINQ初始化、UI 事件响应(低频点击)、加载/存档。
### 3.5 装箱避免
```csharp
// ❌ 值类型传给 object 参数
void Log(object value) { } // 调用 Log(42) 会装箱 int
// ❌ 枚举作为字典键(如果没有实现 IEqualityComparer
Dictionary<SkillType, SkillBase> dict; // SkillType 是 enum默认比较器会装箱
// ✅ 为枚举字典提供专用比较器
public struct SkillTypeComparer : IEqualityComparer<SkillType>
{
public bool Equals(SkillType x, SkillType y) => x == y;
public int GetHashCode(SkillType obj) => (int)obj;
}
var dict = new Dictionary<SkillType, SkillBase>(new SkillTypeComparer());
```
### 3.6 struct 使用规范
```csharp
// ✅ 短期使用、小数据量、值语义的数据适合 struct
public struct AttackResult // 战斗结果传递,无需 GC
{
public int Damage;
public bool IsKill;
public bool IsCritical;
}
// ✅ 事件系统已经使用 structUIEvents保持不变
public struct ShowUINotifyMoment // 零 GC 事件分发
{
public MomentType MomentType;
public CityData CityData; // 引用类型成员不影响 struct 本身
}
```
**struct 使用条件**
- 大小不超过 64 字节
- 不需要继承
- 创建后不会被修改(或修改成本可接受)
- 不会被装箱(不存入 object 或接口引用)
---
## 第四章 计算优化标准
### 4.1 缓存 + 脏标记模式
适用于结果稳定、查询频繁但修改不频繁的计算。
```csharp
/// <summary>
/// 玩家领土格子集合缓存。
/// 仅在领土变更时置脏,查询时按需重建。
/// </summary>
[MemoryPackable]
public partial class PlayerData
{
// ===== 已有序列化字段(不动)=====
// ===== 缓存字段 =====
[MemoryPackIgnore] private HashSet<uint> _territoryCache;
[MemoryPackIgnore] private bool _territoryDirty = true;
public HashSet<uint> GetTerritoryGridIdSet(MapData map)
{
if (_territoryDirty || _territoryCache == null)
{
_territoryCache ??= new HashSet<uint>();
_territoryCache.Clear();
// 从城市列表中收集领土
foreach (var city in map.CityMap.CityList)
{
if (map.CityToPlayerDict.TryGetValue(city.Id, out var pid) && pid == Id)
city.Territory.GetAllTerritoryArea(_territoryCache);
}
_territoryDirty = false;
}
return _territoryCache;
}
/// <summary>领土变更时调用(城市升级、征服、丢失城市)</summary>
public void InvalidateTerritoryCache() => _territoryDirty = true;
}
```
**适用场景**
| 缓存对象 | 失效条件 | 查询频率 |
|---------|---------|---------|
| 玩家领土集合 | 城市升级 / 征服 / 丢失 | 每回合多次AI评分、视野、建筑升级 |
| 玩家部队列表 | 部队创建 / 阵亡 / 城市易主 | 每回合多次AI决策 |
| 技能查找字典 | 技能添加 / 移除 | 战斗计算、回合结算 |
| 城市驻军列表 | 部队移动 / 阵亡 | 每次攻击判定 |
### 4.2 查找表优化
#### 4.2.1 O(n) 线性搜索 → O(1) 字典查找
```csharp
// ❌ 当前:遍历所有城市查找领土归属
public bool GetCityDataByTerritoryGridId(uint gid, out CityData cityData)
{
foreach (var city in CityList) // O(城市数)
{
if (city.CheckIsInTerritory(gid)) // O(领土格子数)
{
cityData = city;
return true;
}
}
// ...
}
// ✅ 维护 格子ID → 城市 的反向映射
[MemoryPackIgnore]
private Dictionary<uint, CityData> _gridToCityLookup;
public bool GetCityDataByTerritoryGridId_Fast(uint gid, out CityData cityData)
{
if (_gridToCityLookup == null) RebuildGridToCityLookup();
return _gridToCityLookup.TryGetValue(gid, out cityData); // O(1)
}
// 城市领土变更时调用
public void InvalidateGridToCityLookup() => _gridToCityLookup = null;
```
#### 4.2.2 MapData 中已有的映射表利用
MapData 中已维护以下映射关系,优化时**优先使用已有映射**,避免重复建表:
```
CityToPlayerDict 城市ID → 玩家ID
UnitToCityDict 部队ID → 城市ID
UnitToGridDict 部队ID → 格子ID
CityToGridDict 城市ID → 格子ID
GridToCityDict 格子ID → 城市ID中心格子
```
### 4.3 算法复杂度优化
#### 4.3.1 BFS/邻居搜索优化
`GetAroundGridData` 是最高频的空间查询方法,当前实现为三重嵌套循环:
```csharp
// ❌ 当前triple nested loop + 距离判断冗余
for (int r = 0; r <= radius; r++)
for (int x = center.X - offset; x <= center.X + offset; x++)
for (int y = center.Y - offset; y <= center.Y + offset; y++)
if (Mathf.Max(Mathf.Abs(x-cx), Mathf.Abs(y-cy)) != r) continue;
// ✅ 优化:直接按距离环遍历,消除冗余条件判断
// 对于小 radius1-3使用预计算偏移表
private static readonly Vector2Int[][] _neighborOffsets = PrecomputeOffsets(maxRadius: 5);
public void GetAroundGridData(GridData center, int radius, List<GridData> buffer)
{
buffer.Clear();
buffer.Add(center); // 中心格子
for (int r = 1; r <= radius; r++)
{
var offsets = _neighborOffsets[r];
for (int i = 0; i < offsets.Length; i++)
{
int x = center.Pos.x + offsets[i].x;
int y = center.Pos.y + offsets[i].y;
if (GetGridDataByPos(x, y, out var grid))
buffer.Add(grid);
}
}
}
```
#### 4.3.2 O(n²) 外交计算优化
```csharp
// ❌ 当前O(玩家数²) 的外交关系计算
foreach (var p1 in players)
foreach (var p2 in players)
foreach (var strategy in p1.GetFeelingStrategies(p2))
Calculate(strategy);
// ✅ 优化:增量计算 + 仅在外交事件触发时更新
// 方案:事件驱动的增量更新
public void OnDiplomacyEvent(uint player1Id, uint player2Id)
{
// 只重算涉及的两个玩家的外交关系
RecalcFeelingBetween(player1Id, player2Id);
}
```
#### 4.3.3 视野计算优化
```csharp
// ❌ 当前:每次移动都完整重算所有部队+城市的视野集合
UpdateSight_LogicView(player); // 遍历所有部队、所有城市领土
// ✅ 增量视野更新
public void UpdateSightIncremental(MapData map, PlayerData player,
UnitData movedUnit, Vector2Int oldPos, Vector2Int newPos)
{
// 1. 移除旧位置视野贡献
RemoveSightContribution(movedUnit, oldPos);
// 2. 添加新位置视野贡献
AddSightContribution(movedUnit, newPos);
// 比完整重算快 O(部队数 × 视野范围²) → O(视野范围²)
}
```
### 4.4 循环优化
#### 4.4.1 for 替代 foreach仅对 List\<T\>
```csharp
// ✅ List<T> 使用 for 循环(避免 enumerator 分配,虽然 List 的 enumerator 是 struct
for (int i = 0; i < list.Count; i++)
{
var item = list[i];
// ...
}
// ✅ Dictionary 的 foreach 是安全的struct enumerator
foreach (var kv in dict) { } // OK
// ✅ HashSet 的 foreach 也是安全的struct enumerator
foreach (var item in set) { } // OK
```
#### 4.4.2 提前退出优化
```csharp
// ❌ 遍历完整列表仅为找到一个元素
foreach (var unit in allUnits)
{
if (unit.IsAlive() && unit.OwnerId == playerId)
{
result = unit;
// 没有 break继续遍历...
}
}
// ✅ 找到即退出
foreach (var unit in allUnits)
{
if (unit.IsAlive() && unit.OwnerId == playerId)
{
result = unit;
break;
}
}
```
#### 4.4.3 条件外提
```csharp
// ❌ 循环内重复计算不变量
for (int i = 0; i < units.Count; i++)
{
var grid = map.GridMap.GetGridDataByGid(units[i].GridId);
var player = map.PlayerMap.GetPlayerById(playerId); // 每次循环都查找同一个 player
// ...
}
// ✅ 循环不变量外提
var player = map.PlayerMap.GetPlayerById(playerId);
for (int i = 0; i < units.Count; i++)
{
var grid = map.GridMap.GetGridDataByGid(units[i].GridId);
// ...
}
```
### 4.5 数学计算优化
```csharp
// ❌ 使用 Mathf浮点 + Unity 函数调用开销)
int dist = Mathf.Max(Mathf.Abs(x1 - x2), Mathf.Abs(y1 - y2));
// ✅ 使用纯整数数学
int dist = Math.Max(Math.Abs(x1 - x2), Math.Abs(y1 - y2));
// ❌ 重复计算距离
for (int i = 0; i < targets.Count; i++)
{
float dist = Vector2.Distance(pos, targets[i].Pos); // sqrt 开销
if (dist < minDist) { ... }
}
// ✅ 使用距离平方比较(避免 sqrt
for (int i = 0; i < targets.Count; i++)
{
float distSq = (pos - targets[i].Pos).sqrMagnitude;
if (distSq < minDistSq) { ... }
}
```
---
## 第五章 模块专项优化指南
### 5.1 数据层TH1_Data
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|-------|---------|---------|-----------|--------|
| `GetPlayerTerritoryGridIdSet` | 每次调用 new HashSet + new List | 玩家级缓存 + 脏标记 | `[MemoryPackIgnore]` 无影响 | P0 |
| `GetCityDataByTerritoryGridId` | O(城市数×领土数) 线性搜索,代码中标注 TODO | 维护格子→城市反向映射 | `[MemoryPackIgnore]` 无影响 | P0 |
| `GetAroundGridData` (4个重载) | 每次调用 new List/HashSet | 传入缓冲区模式 | 无 | P0 |
| `GetAroundGridIdList` | 每次调用 new List | 传入缓冲区模式 | 无 | P0 |
| `GetUnitDataListByPlayerId` | null 参数时隐式分配 | 强制传入缓冲区 | 无 | P1 |
| `GetCityDataListByPlayerId` | null 参数时隐式分配 | 强制传入缓冲区 | 无 | P1 |
| OnAfterMemoryPackDeserialize | 每次反序列化重建全部字典 | 延迟初始化(首次访问时重建) | 无 | P1 |
### 5.2 技能系统TH1_Logic/Skill
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|-------|---------|---------|-----------|--------|
| SkillFactory | 每次创建技能用 Activator.CreateInstance | 编译时委托缓存 | 无 | P1 |
| 技能查找 | Skills 列表 O(n) 线性搜索 | `[MemoryPackIgnore]` 字典缓存 + 脏标记 | 无影响 | P1 |
| GetAttackAdditionParam | 遍历所有技能聚合加成值 | 缓存加成总值 + 技能变更时置脏 | `[MemoryPackIgnore]` 无影响 | P2 |
| OnTurnStart/End 技能回调 | 逐个遍历调用虚方法 | 仅遍历注册了该回调的技能(分类桶) | `[MemoryPackIgnore]` 无影响 | P2 |
### 5.3 AI系统TH1_Logic/AI
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|-------|---------|---------|-----------|--------|
| AIActionGenerator.Init | 复制领土到新 List冗余遍历 | 直接引用已有缓存,减少拷贝 | 无 | P0 |
| AILogic 主循环 | 循环内 new List\<uint\>() | 预分配 NodeRecords 池 | 无 | P1 |
| 评分计算器 | 7个集合每次 Init 重建 | 复用集合 + Clear | 无 | P1 |
| AI 合法 Action 生成 | 生成大量临时 CommonActionParams | 对象池复用 | 无 | P2 |
| `validActions.Select().ToArray()` | LINQ 链式分配 | 预分配数组 + 手动填充 | 无 | P1 |
### 5.4 逻辑模块City/Unit/Player Logic
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|-------|---------|---------|-----------|--------|
| 视野完整重算 | 每次移动重算全部视野 | 增量视野更新 | 无 | P1 |
| 外交好感度 O(n²) | 每帧/每回合全量重算 | 事件驱动增量更新 | 无 | P2 |
| 领土建筑升级遍历 | 遍历领土两遍(普通+学院/市场) | 单次遍历 + 分类处理 | 无 | P2 |
| BFS 城市连通性 | 每回合完整 BFS | 仅在道路/领土变化时重算 | 无 | P2 |
| 每回合资源计算 | 遍历所有领土格子 | 缓存产出总值 + 建筑变更时增量更新 | `[MemoryPackIgnore]` 无影响 | P3 |
### 5.5 渲染与动画TH1_Renderer / TH1_Anim
| 优化项 | 当前问题 | 优化方案 | 优先级 |
|-------|---------|---------|--------|
| Fragment 创建 | FragmentFactory 每次创建新对象 | 对象池复用 Fragment 实例 | P2 |
| UnitAtomAnim | 每次创建新动画对象 | 对象池复用 | P2 |
| 投射物 | ProjectileRenderer 每次实例化 | 已有 PrefabPool确保使用 | P3 |
### 5.6 事件系统TH1_Core/Events
事件系统已使用 struct 事件,零 GC 分配,**保持现有设计,不需优化**。
---
## 第六章 优化验证清单
### 6.1 每次优化必须验证
- [ ] **功能等价**:优化前后方法的输入参数和返回结果完全一致
- [ ] **单人游戏**:完整对局流程无报错、无异常行为
- [ ] **多人游戏**两个客户端对局Action 同步正确、MapConfirm 校验通过
- [ ] **存档兼容**:用优化前版本创建的存档能被优化后版本正常加载
- [ ] **AI 行为**AI 对局结果分布无异常偏移(相同种子应产生相同决策路径)
### 6.2 性能验证方法
```
1. Unity Profiler → 对比优化前后:
- GC Alloc (bytes/frame)
- 目标方法的 CPU 时间 (ms)
2. Deep Profile 模式 → 确认热路径 GC 分配降为 0
3. 多人对局 → 确认 MapConfirm 哈希始终一致
4. 存档测试 → 加载旧存档 → 进行 10 回合 → 再次存档 → 加载验证
```
### 6.3 回归测试范围
| 优化区域 | 最小测试范围 |
|---------|------------|
| 数据层MapData/GridData | 单人完整对局 + 多人2人对局 |
| 技能系统 | 包含全部8文明的对局 + 英雄技能触发 |
| AI系统 | 8人AI全自动对局200回合 |
| 逻辑模块 | 征服他国 + 联盟 + 奇迹建造 |
| 视野/领土 | 大地图 + 地形多样 |
---
## 附录A 已知GC热点清单
### 按严重程度排序
| # | 热点 | 位置 | 调用频率 | 分配类型 | 严重度 |
|---|------|------|---------|---------|--------|
| 1 | `GetAroundGridData` (4个重载) | GridData.cs | 每回合 100+ 次 | new List / new HashSet | 🔴 严重 |
| 2 | `GetPlayerTerritoryGridIdSet` | MapData.cs | 每回合 3-10 次 | new HashSet + new List | 🔴 严重 |
| 3 | `OnAfterMemoryPackDeserialize` (4处) | CityData/GridData/UnitData | 每次网络同步 | Dictionary 重建 | 🔴 严重 |
| 4 | `AIActionGenerator.Init` | AIActionGenerator.cs | 每个AI回合 | 列表拷贝 + HashSet | 🟡 中等 |
| 5 | `SkillFactory.GetSkillBySkillType` | SkillFactory.cs | 技能创建时 | Activator.CreateInstance | 🟡 中等 |
| 6 | `GetUnitDataListByPlayerId/ByCityId` | MapData.cs | 每回合多次 | null 参数时 new List | 🟡 中等 |
| 7 | AI主循环 NodeRecords | AILogic.cs | 每AI步 | new List\<uint\>() | 🟡 中等 |
| 8 | `validActions.Select().ToArray()` | AILogic.cs | AI 推理时 | LINQ 链分配 | 🟡 中等 |
| 9 | `UpdateTerritoryAllBuildingLevel` | PlayerLogic.cs | 每回合 | 领土集合分配 x2 | 🟢 轻度 |
| 10 | `GetCityDataByTerritoryGridId` | MapData.cs | 按需调用 | 仅 CPU 开销O(n²) | 🟢 轻度 |
---
## 附录B 禁止与允许操作速查表
### 🟢 允许的优化操作
| 操作 | 条件 |
|------|------|
| 新增 `[MemoryPackIgnore]` 字段作为缓存 | 配合延迟初始化和脏标记 |
| 将 `new List<T>()` 改为传入缓冲区模式 | 调用方和被调用方同步修改 |
| 用 for 循环替代 LINQ | 保证结果等价 |
| 新增 static readonly 共享缓冲区 | 仅限单线程游戏逻辑 |
| 预计算偏移表/查找表 | 只读数据,初始化时构建 |
| 对象池复用 | 正确的获取/归还生命周期 |
| 增量计算替代全量计算 | 脏标记在所有修改路径上正确设置 |
### 🔴 禁止的操作
| 操作 | 原因 |
|------|------|
| 向 `[MemoryPackable]` 类新增序列化字段 | 破坏存档兼容性和网络协议 |
| 删除或重命名已有的序列化字段 | 旧存档无法加载 |
| 修改方法的返回值类型或语义 | 违反 R-01 规则 |
| 在缓存中引入浮点累积误差 | 多人同步会出现不一致 |
| 使用多线程/异步优化游戏逻辑计算 | 破坏确定性 |
| 跳过 null 检查以"加速" | 引入崩溃风险 |
| 改变遍历顺序导致不同的执行结果 | 影响确定性AI/战斗/随机数消费) |
### 🟡 需审慎评估的操作
| 操作 | 评估点 |
|------|--------|
| 修改 `[MemoryPackOnDeserialized]` 逻辑 | 确保所有缓存在反序列化后正确重建 |
| static 共享缓冲区 | 确保单线程下无递归/重入问题 |
| 缓存失效策略 | 确保所有修改路径都调用 Invalidate |
| 移除冗余集合 | 确认没有外部引用依赖该集合 |
| 改变内部数据结构(如 List → Array | 确认所有增删操作都适配 |
---
> **文档版本**: 1.0
> **最后更新**: 2026-04-16
> **适用项目**: TH1 回合制4X策略游戏

View File

@ -0,0 +1,677 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
public class ProfilerAutoInstrumentWindow : EditorWindow
{
private const string DefaultFolder = "Assets/Scripts/TH1_Logic";
private const string MarkerBegin = "// TH1_AUTO_PROFILE_BEGIN";
private const string MarkerEnd = "// TH1_AUTO_PROFILE_END";
private const string IndentUnit = " ";
private static readonly HashSet<string> NonMethodKeywords = new HashSet<string>(StringComparer.Ordinal)
{
"if", "for", "foreach", "while", "switch", "catch", "lock", "using", "nameof", "typeof", "sizeof", "default"
};
private string _targetFolder = DefaultFolder;
private Vector2 _scrollPos;
private string _lastReport = string.Empty;
private enum BlockKind
{
Other,
Type,
Method
}
private struct BlockInfo
{
public BlockKind Kind;
public int MethodIndex;
}
private struct MethodBlock
{
public int OpenBraceIndex;
public int CloseBraceIndex;
public string MethodName;
}
private enum InstrumentResult
{
Changed,
AlreadyInstrumented,
NoMethod,
Failed
}
[MenuItem("Tools/TH1/Profiler Auto Instrument")]
public static void ShowWindow()
{
var window = GetWindow<ProfilerAutoInstrumentWindow>("Profiler Auto Instrument");
window.minSize = new Vector2(720, 420);
}
private void OnGUI()
{
EditorGUILayout.LabelField("批量自动打点Profiler Begin/End", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"会在目录下所有 .cs 文件的方法体内自动插入 Profiler.BeginSample/EndSample。\n" +
"默认目录Assets/Scripts/TH1_Logic\n" +
$"已插入的代码会带标记:{MarkerBegin}",
MessageType.Info);
EditorGUILayout.Space(6);
EditorGUILayout.BeginHorizontal();
_targetFolder = EditorGUILayout.TextField("目标目录", _targetFolder);
if (GUILayout.Button("选择...", GUILayout.Width(80)))
{
SelectFolder();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8);
if (GUILayout.Button("一键自动打点", GUILayout.Height(34)))
{
RunAutoInstrument();
}
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("执行结果", EditorStyles.boldLabel);
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
EditorGUILayout.TextArea(string.IsNullOrEmpty(_lastReport) ? "尚未执行。" : _lastReport, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
}
private void SelectFolder()
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var selected = EditorUtility.OpenFolderPanel("选择要自动打点的目录", projectRoot, string.Empty);
if (string.IsNullOrEmpty(selected))
{
return;
}
selected = Path.GetFullPath(selected);
if (!selected.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
{
EditorUtility.DisplayDialog("目录无效", "请选择项目目录内的文件夹。", "确定");
return;
}
var relative = selected.Substring(projectRoot.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
_targetFolder = relative.Replace("\\", "/");
}
private void RunAutoInstrument()
{
var folder = NormalizeFolderPath(_targetFolder);
if (!AssetDatabase.IsValidFolder(folder))
{
EditorUtility.DisplayDialog("目录不存在", $"找不到目录:{folder}", "确定");
return;
}
var folderAbsolute = AssetPathToAbsolutePath(folder);
var files = Directory.GetFiles(folderAbsolute, "*.cs", SearchOption.AllDirectories);
int changed = 0;
int already = 0;
int noMethod = 0;
int failed = 0;
var details = new StringBuilder();
for (int i = 0; i < files.Length; i++)
{
var fullPath = files[i];
var assetPath = AbsolutePathToAssetPath(fullPath);
var result = InstrumentSingleFile(fullPath, assetPath, out var detail);
switch (result)
{
case InstrumentResult.Changed:
changed++;
details.AppendLine($"[Changed] {assetPath}");
break;
case InstrumentResult.AlreadyInstrumented:
already++;
break;
case InstrumentResult.NoMethod:
noMethod++;
break;
case InstrumentResult.Failed:
failed++;
details.AppendLine($"[Failed] {assetPath} -> {detail}");
break;
default:
throw new ArgumentOutOfRangeException();
}
}
AssetDatabase.Refresh();
_lastReport =
$"目录:{folder}\n" +
$"扫描文件:{files.Length}\n" +
$"已打点:{changed}\n" +
$"已存在打点:{already}\n" +
$"无可打点方法:{noMethod}\n" +
$"失败:{failed}\n\n" +
details;
Debug.Log($"[ProfilerAutoInstrument] 完成。Changed={changed}, Already={already}, NoMethod={noMethod}, Failed={failed}");
}
private static InstrumentResult InstrumentSingleFile(string fullPath, string assetPath, out string detail)
{
detail = string.Empty;
try
{
var rawBytes = File.ReadAllBytes(fullPath);
bool hasUtf8Bom = rawBytes.Length >= 3 &&
rawBytes[0] == 0xEF &&
rawBytes[1] == 0xBB &&
rawBytes[2] == 0xBF;
var original = File.ReadAllText(fullPath);
if (original.Contains(MarkerBegin))
{
return InstrumentResult.AlreadyInstrumented;
}
var methods = FindMethodBlocks(original);
if (methods.Count == 0)
{
return InstrumentResult.NoMethod;
}
var instrumented = BuildInstrumentedContent(original, methods, assetPath);
if (instrumented == original)
{
return InstrumentResult.NoMethod;
}
File.WriteAllText(fullPath, instrumented, new UTF8Encoding(hasUtf8Bom));
return InstrumentResult.Changed;
}
catch (Exception ex)
{
detail = ex.Message;
return InstrumentResult.Failed;
}
}
private static List<MethodBlock> FindMethodBlocks(string content)
{
var codeOnly = BuildCodeOnlyBuffer(content);
var methods = new List<MethodBlock>();
var stack = new Stack<BlockInfo>();
int typeDepth = 0;
int methodDepth = 0;
for (int i = 0; i < codeOnly.Length; i++)
{
char c = codeOnly[i];
if (c == '{')
{
var block = new BlockInfo { Kind = BlockKind.Other, MethodIndex = -1 };
if (LooksLikeTypeBlock(codeOnly, i))
{
block.Kind = BlockKind.Type;
typeDepth++;
}
else if (typeDepth > 0 && methodDepth == 0 && TryResolveMethodName(codeOnly, i, out var methodName))
{
var method = new MethodBlock
{
OpenBraceIndex = i,
CloseBraceIndex = -1,
MethodName = methodName
};
methods.Add(method);
block.Kind = BlockKind.Method;
block.MethodIndex = methods.Count - 1;
methodDepth++;
}
stack.Push(block);
continue;
}
if (c != '}' || stack.Count == 0)
{
continue;
}
var closedBlock = stack.Pop();
if (closedBlock.Kind == BlockKind.Type)
{
typeDepth = Math.Max(0, typeDepth - 1);
}
else if (closedBlock.Kind == BlockKind.Method)
{
methodDepth = Math.Max(0, methodDepth - 1);
if (closedBlock.MethodIndex >= 0 && closedBlock.MethodIndex < methods.Count)
{
var method = methods[closedBlock.MethodIndex];
method.CloseBraceIndex = i;
methods[closedBlock.MethodIndex] = method;
}
}
}
methods.RemoveAll(m => m.CloseBraceIndex <= m.OpenBraceIndex);
return methods;
}
private static string BuildInstrumentedContent(string content, List<MethodBlock> methods, string assetPath)
{
string newline = DetectNewLine(content);
var output = new StringBuilder(content.Length + methods.Count * 220);
int cursor = 0;
for (int i = 0; i < methods.Count; i++)
{
var method = methods[i];
if (method.OpenBraceIndex < cursor || method.CloseBraceIndex <= method.OpenBraceIndex)
{
continue;
}
output.Append(content, cursor, method.OpenBraceIndex + 1 - cursor);
string braceIndent = GetLineIndent(content, method.OpenBraceIndex);
string innerIndent = braceIndent + IndentUnit;
string sampleName = BuildSampleName(assetPath, method.MethodName);
output.Append(newline);
output.Append(innerIndent).Append(MarkerBegin).Append(newline);
output.Append(innerIndent).Append("UnityEngine.Profiling.Profiler.BeginSample(\"").Append(sampleName).Append("\");").Append(newline);
output.Append(innerIndent).Append("try").Append(newline);
output.Append(innerIndent).Append("{").Append(newline);
int bodyStart = method.OpenBraceIndex + 1;
output.Append(content, bodyStart, method.CloseBraceIndex - bodyStart);
output.Append(newline);
output.Append(innerIndent).Append("}").Append(newline);
output.Append(innerIndent).Append("finally").Append(newline);
output.Append(innerIndent).Append("{").Append(newline);
output.Append(innerIndent).Append(IndentUnit).Append("UnityEngine.Profiling.Profiler.EndSample();").Append(newline);
output.Append(innerIndent).Append("}").Append(newline);
output.Append(innerIndent).Append(MarkerEnd).Append(newline);
output.Append(braceIndent);
cursor = method.CloseBraceIndex;
}
if (cursor < content.Length)
{
output.Append(content, cursor, content.Length - cursor);
}
return output.ToString();
}
private static string BuildSampleName(string assetPath, string methodName)
{
var normalizedPath = assetPath.Replace("\\", "/");
var sample = $"TH1Auto/{normalizedPath}::{methodName}";
return sample.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
private static bool LooksLikeTypeBlock(string codeOnly, int braceIndex)
{
int headerStart = FindHeaderStart(codeOnly, braceIndex);
int length = braceIndex - headerStart;
if (length <= 0)
{
return false;
}
string header = codeOnly.Substring(headerStart, length);
if (Regex.IsMatch(header, @"\bnamespace\b"))
{
return false;
}
return Regex.IsMatch(header, @"\b(class|struct|interface|enum|record)\b");
}
private static bool TryResolveMethodName(string codeOnly, int braceIndex, out string methodName)
{
methodName = null;
int segmentStart = FindHeaderStart(codeOnly, braceIndex);
int closeParen = FindPreviousChar(codeOnly, ')', braceIndex - 1);
while (closeParen >= 0 && closeParen >= segmentStart)
{
int openParen = FindMatchingOpenParen(codeOnly, closeParen);
if (openParen < 0 || openParen < segmentStart)
{
return false;
}
int nameEnd = PreviousNonWhitespace(codeOnly, openParen - 1);
if (nameEnd < 0)
{
return false;
}
int nameStart = nameEnd;
while (nameStart >= 0 && IsIdentifierChar(codeOnly[nameStart]))
{
nameStart--;
}
nameStart++;
if (nameStart > nameEnd)
{
return false;
}
methodName = codeOnly.Substring(nameStart, nameEnd - nameStart + 1);
if (string.IsNullOrEmpty(methodName))
{
return false;
}
if (methodName[0] == '@')
{
methodName = methodName.Substring(1);
}
if (methodName == "base" || methodName == "this")
{
closeParen = FindPreviousChar(codeOnly, ')', openParen - 1);
continue;
}
if (NonMethodKeywords.Contains(methodName))
{
return false;
}
int checkLength = braceIndex - segmentStart;
if (checkLength <= 0)
{
return false;
}
string header = codeOnly.Substring(segmentStart, checkLength);
if (header.Contains("=>") || header.Contains(";") || header.Contains("="))
{
return false;
}
if (Regex.IsMatch(header, @"\b(return|throw)\b"))
{
return false;
}
return true;
}
return false;
}
private static string BuildCodeOnlyBuffer(string content)
{
var chars = content.ToCharArray();
bool inSingleLineComment = false;
bool inMultiLineComment = false;
bool inString = false;
bool inChar = false;
bool verbatimString = false;
for (int i = 0; i < chars.Length; i++)
{
char c = chars[i];
char next = i + 1 < chars.Length ? chars[i + 1] : '\0';
if (inSingleLineComment)
{
if (c != '\r' && c != '\n')
{
chars[i] = ' ';
}
else
{
inSingleLineComment = false;
}
continue;
}
if (inMultiLineComment)
{
chars[i] = ' ';
if (c == '*' && next == '/')
{
chars[i + 1] = ' ';
i++;
inMultiLineComment = false;
}
continue;
}
if (inString)
{
chars[i] = ' ';
if (verbatimString)
{
if (c == '"' && next == '"')
{
chars[i + 1] = ' ';
i++;
}
else if (c == '"')
{
inString = false;
verbatimString = false;
}
}
else
{
if (c == '\\' && i + 1 < chars.Length)
{
chars[i + 1] = ' ';
i++;
}
else if (c == '"')
{
inString = false;
}
}
continue;
}
if (inChar)
{
chars[i] = ' ';
if (c == '\\' && i + 1 < chars.Length)
{
chars[i + 1] = ' ';
i++;
}
else if (c == '\'')
{
inChar = false;
}
continue;
}
if (c == '/' && next == '/')
{
chars[i] = ' ';
chars[i + 1] = ' ';
i++;
inSingleLineComment = true;
continue;
}
if (c == '/' && next == '*')
{
chars[i] = ' ';
chars[i + 1] = ' ';
i++;
inMultiLineComment = true;
continue;
}
if (c == '"')
{
chars[i] = ' ';
inString = true;
verbatimString =
(i > 0 && chars[i - 1] == '@') ||
(i > 1 && chars[i - 1] == '$' && chars[i - 2] == '@') ||
(i > 1 && chars[i - 1] == '@' && chars[i - 2] == '$');
continue;
}
if (c == '\'')
{
chars[i] = ' ';
inChar = true;
}
}
return new string(chars);
}
private static int FindMatchingOpenParen(string codeOnly, int closeParenIndex)
{
int depth = 0;
for (int i = closeParenIndex; i >= 0; i--)
{
char c = codeOnly[i];
if (c == ')')
{
depth++;
continue;
}
if (c != '(')
{
continue;
}
depth--;
if (depth == 0)
{
return i;
}
}
return -1;
}
private static int FindHeaderStart(string codeOnly, int index)
{
for (int i = index - 1; i >= 0; i--)
{
char c = codeOnly[i];
if (c == ';' || c == '{' || c == '}')
{
return i + 1;
}
}
return 0;
}
private static int PreviousNonWhitespace(string text, int index)
{
for (int i = index; i >= 0; i--)
{
if (!char.IsWhiteSpace(text[i]))
{
return i;
}
}
return -1;
}
private static int FindPreviousChar(string text, char target, int startIndex)
{
for (int i = startIndex; i >= 0; i--)
{
char c = text[i];
if (c == target)
{
return i;
}
if (c == ';' || c == '{' || c == '}')
{
return -1;
}
}
return -1;
}
private static bool IsIdentifierChar(char c)
{
return char.IsLetterOrDigit(c) || c == '_' || c == '@';
}
private static string DetectNewLine(string content)
{
return content.Contains("\r\n") ? "\r\n" : "\n";
}
private static string GetLineIndent(string content, int index)
{
int lineStart = content.LastIndexOf('\n', Mathf.Clamp(index, 0, Math.Max(0, content.Length - 1)));
lineStart = lineStart < 0 ? 0 : lineStart + 1;
int i = lineStart;
while (i < content.Length && (content[i] == ' ' || content[i] == '\t'))
{
i++;
}
return content.Substring(lineStart, i - lineStart);
}
private static string NormalizeFolderPath(string folder)
{
if (string.IsNullOrWhiteSpace(folder))
{
return DefaultFolder;
}
var normalized = folder.Replace("\\", "/").Trim();
if (!normalized.StartsWith("Assets", StringComparison.OrdinalIgnoreCase))
{
normalized = "Assets/" + normalized.TrimStart('/');
}
return normalized.TrimEnd('/');
}
private static string AssetPathToAbsolutePath(string assetPath)
{
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
}
private static string AbsolutePathToAssetPath(string absolutePath)
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var full = Path.GetFullPath(absolutePath);
var relative = full.Substring(projectRoot.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return relative.Replace("\\", "/");
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f2d8ec8a8d147f5abdf1f4b7f940a11
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: