Pal3.Unity开源项目复刻(七)Scene
AttributeUsage 是一个元特性 (Meta-Attribute),也就是“用来修饰特性的特性”。它就像一份使用说明书。它告诉编译器和其他开发者:这个特性(AvailableInConsoleAttribute)应该怎么用,能用在哪里,能不能用多次。默认情况下,C# 允许你把特性贴在任何地方(类、方法、属性、字段...),并且不限制次数。但这在业务逻辑上往往是不对的。AttributeT
在管理游戏场景时使用了命令模式 (Command Pattern) 的一种变体:Command Dispatcher (命令分发器)。
在传统的 OOP中,直接调用 SceneManager->LoadScene("m01") 。
但在现在的 C# 架构中,它被解耦了:
-
发送者 (可能是 UI 按钮、脚本解析器、触发器) 创建一个 SceneLoadCommand 数据包。
-
分发者 (CommandDispatcher) 找到谁能处理这个命令。
-
执行者 (SceneManager) 实现 ICommandExecutor<SceneLoadCommand> 接口,接收并执行它。
这种设计的好处是:
-
解耦: UI 不需要知道 SceneManager 存在,只需要知道“我要发个命令加载场景”。
-
调试: 你可以在 CommandDispatcher 里打断点,拦截所有命令,看看系统都在干嘛。
-
重放: 你可以把命令序列化存下来,以后用来做自动化测试(Replay System)。
public sealed class SceneManager : IDisposable,
ICommandExecutor<SceneLoadCommand>,
ICommandExecutor<SceneObjectDoNotLoadFromSaveStateCommand>,
ICommandExecutor<ResetGameStateCommand>
这些继承代表着SceneManager是SceneLoadCommand等这些命令的执行者。
作为命令执行者ICommandExecutor<in T>,SceneManager需要实现Excute函数,对相应的命令进行处理:
public void Execute(SceneLoadCommand command) => LoadScene(command.SceneCityName, command.SceneName);
public void Execute(ResetGameStateCommand command) => DisposeCurrentScene();
public void Execute(SceneObjectDoNotLoadFromSaveStateCommand command)
{
_sceneObjectIdsToNotLoadFromSaveState.Add(command.ObjectId);
}
协变out与逆变in
只能使用于泛型接口和泛型委托。
子类当父类使用:
IFileReader<T> fileReader = ServiceLocator.Instance.Get<IFileReader<T>>();
这里将子类PolFileReader当作了父类IFileReader<PolFile>来使用。
协变out标注的类型只能够用于输出:
public interface IFileReader<out T>
{
public T Read(IBinaryReader reader, int codepage = 936);
这里T作为返回值
}
当你把 IFileReader<PolFile> 当作 IFileReader<object> 使用时,别人可能会传一个 String 进去,这会直接破坏内存安全性
逆变in
父类当子类使用:
IList<ICommandExecutor<T>> executors = _commandExecutorRegistry.GetRegisteredExecutors<T>().ToList();
if (!executors.Any()) return false;
foreach (ICommandExecutor<T> executor in executors)
{
executor.Execute(command);
}
在CommandDispatcher中,ICommandExecutor<T>可以被当成具体的子类如SceneManager来调用具体的Execute。
逆变in标注的类型只能用于输入:
public interface ICommandExecutor<in TCommand>
{
public void Execute(TCommand command);
这里TCommand作为参数
}
如果不对输出进行限制,那么对于ICommandExecutor<in TCommand>来说,它可以返回任意一个ICommandExecutor<in TCommand>类,但对于子类来说,它必须接受特定类型。
自定义特性Attribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class AvailableInConsoleAttribute : Attribute
{
public AvailableInConsoleAttribute() { }
}
AttributeUsage 是一个元特性 (Meta-Attribute),也就是“用来修饰特性的特性”。
-
作用: 它就像一份使用说明书。它告诉编译器和其他开发者:这个特性(AvailableInConsoleAttribute)应该怎么用,能用在哪里,能不能用多次。
-
如果不写: 默认情况下,C# 允许你把特性贴在任何地方(类、方法、属性、字段...),并且不限制次数。但这在业务逻辑上往往是不对的。
AttributeTargets.Class是 AttributeUsage 的第一个参数,规定了适用范围。
-
含义: AttributeTargets.Class 表示这个特性只能贴在 class (类) 上面。
-
如果你试图把它贴在方法上:[AvailableInConsole] void MyMethod() -> 编译器报错!
-
如果你试图把它贴在属性上:[AvailableInConsole] int MyProperty -> 编译器报错!
-
-
为什么这么设计?
回想一下上一题的 ResetGameStateCommand。它是一个类。
我们的业务逻辑是:只有命令类本身才需要被标记为“控制台可用”。把这个标记贴在命令类内部的某个字段上是毫无意义的,系统扫描不到。
所以,强制限制为 Class 可以防止开发者手滑贴错地方,导致功能失效。 -
AllowMultiple = false:
这也是 AttributeUsage 的参数。-
含义: 一个类上面最多只能贴一次这个特性
-
[AvailableInConsole] [AvailableInConsole] // ❌ 编译器报错:Duplicate attribute class MyCommand ... -
业务逻辑: 一个命令要么“可用”,要么“不可用”。贴两次并不会让它变得“非常可用”。所以禁止重复是合理的。
-
public AvailableInConsoleAttribute() { } 是这个特性的构造函数。
-
作用:
当你在代码里写 [AvailableInConsole] 时,编译器实际上是在调用这个构造函数。
虽然它是空的({ }),但它必须存在且为 public,否则你无法在外部使用这个特性。 -
如果你没有定义任何构造函数,编译器会自动生成一个默认的无参构造函数。所以删掉这一行代码也能跑
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class SceCommandAttribute : Attribute
{
public SceCommandAttribute(ushort commandId, string description)
{
CommandId = commandId;
Description = description;
}
public ushort CommandId { get; }
public string Description { get; }
}
同理,这个是SceCommandAttribute类的构造函数
[SceCommand(158, "设置某个物件出场时候的不从存档里拿状态," +
"参数:物件ID")]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class SceActorIdAttribute : Attribute
{
}
SceActorIdAttribute用于标记一个属性,它告诉脚本执行引擎:这个属性存的是 ActorID。如果在执行时发现它的值是 255 (byte),请自动把它替换成当前玩家控制的角色 ID:
预处理 (Preprocessor) / 执行前:
CommandDispatcher 或者 ScriptManager 在执行前,会扫描所有属性。
// 伪代码:在执行前自动修正参数
foreach (var prop in cmd.GetType().GetProperties())
{
// 检查是否有 [SceActorId] 标记
if (prop.GetCustomAttribute<SceActorIdAttribute>() != null)
{
int val = (int)prop.GetValue(cmd);
// 核心逻辑:如果是 255,替换为当前领队
if (val == 255)
{
var leader = ServiceLocator.Instance.Get<ITeamManager>().CurrentLeader;
prop.SetValue(cmd, leader.ActorId); // 动态修改命令参数!
}
}
}
ActorMoveCommand.Execute()
ResetGameStateCommand
[AvailableInConsole]
public sealed class ResetGameStateCommand : ICommand
{
public ResetGameStateCommand() {}
}
这是一个[AvailableInConsole]特性的命令,代表这个命令是安全的,允许在游戏运行时通过控制台手动触发。
它是空的。这很常见,因为“重置游戏”这个动作本身不需要额外参数(不像 LoadScene 需要场景名)。它的存在本身就是一个信号。当系统收到这个类型的对象时,就知道要执行重置逻辑。
完整的调用链:
-
开发者在游戏里按下 ~ 键打开控制台。
-
输入: ResetGameState 并回车。
-
控制台系统:
-
检查 ResetGameStateCommand 类是否有 [AvailableInConsole] 标记? 有!
-
创建 new ResetGameStateCommand() 实例。
-
将实例扔给 CommandDispatcher。
-
-
分发器: 找到 SceneManager 是该命令的执行者。
-
执行: SceneManager.DisposeCurrentScene() 被调用,游戏重置。
SceCommandAttribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class SceCommandAttribute : Attribute
{
public ushort CommandId { get; } // 脚本指令 ID (例如 158)
public string Description { get; } // 开发者文档
}
-
技术本质: 这是一个元数据标记。
-
业务背景:
-
仙剑3 的脚本文件(.sce)是二进制字节码。
-
在 .sce 文件里,并不是写着 DoNotLoadFromSaveState(100) 这样的函数名。
-
而是写着:0x9E 00 64 00 00 00。
-
0x9E (十进制 158) = 指令 ID。
-
00 64 00 00 (十进制 100) = 参数 (ObjectId)。
-
-
-
为什么需要 Attribute?
我们需要建立一个映射表 (Mapping Table): -
ID158 ->SceneObjectDoNotLoadFromSaveStateCommand类
SceneObjectDoNotLoadFromSaveStateCommand
具体的指令载体
[SceCommand(158, "设置某个物件出场时候的不从存档里拿状态," +
"参数:物件ID")]//完成注册
public sealed class SceneObjectDoNotLoadFromSaveStateCommand : ICommand
{
public SceneObjectDoNotLoadFromSaveStateCommand(int objectId)
{
ObjectId = objectId;
}
public int ObjectId { get; }
}
-
这是一个强类型的数据包。
-
作用: 它把晦涩难懂的二进制参数(int objectId),封装成了具有语义的 C# 对象。
-
流程:
-
解析阶段: ISceCommandParser 读取 .sce 文件,读到 158。
-
反射查找: 系统查表发现 158 对应 SceneObjectDoNotLoadFromSaveStateCommand。
-
参数读取: 解析器继续读取后面的 4 个字节,作为 objectId。
-
实例化: new SceneObjectDoNotLoadFromSaveStateCommand(100)。
-
分发: 扔给 CommandDispatcher。
-
执行: SceneManager.Execute(...) 接收并处理。
-
SceneObjectFactory
使用了反射式工厂
1.核心机制:反射扫描 (Reflection Scanning)
IEnumerable<Type> sceneObjectTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => t.IsClass && t.BaseType == typeof(SceneObject));
-
原理: Init() 方法在游戏启动时运行一次。扫描当前的程序集(Game)。Assembly.GetExecutingAssembly().GetTypes()获取当前程序集下的所有类型。
-
筛选条件:
-
必须是 class(不是接口或结构体)。
-
必须继承自 SceneObject 基类(这是所有场景物体的父类)。
-
-
架构优势: 符合 开闭原则 (Open/Closed Principle)。对扩展开放,对修改关闭。你以后新增一个 TrapObject(陷阱),只需要加上Attribute即可,完全不需要动 SceneObjectFactory.cs 一行代码。
2. 核心机制:元数据映射 (Metadata Mapping)
foreach (Attribute attribute in objectType.GetCustomAttributes(typeof(ScnSceneObjectAttribute)))
{
// ...
_sceneObjectTypeCache[sceneObjectAttribute.Type] = objectType;
}
-
原理: 这里使用了我们之前讨论过的 Attribute 技术。
-
映射关系: 它建立了一个从 枚举 (Enum) 到 类型 (Type) 的查找表:
-
SceneObjectType.Npc -> typeof(NpcObject)
-
SceneObjectType.Chest -> typeof(ChestObject)
-
3. 核心机制:构造函数契约 (Constructor Contract)
ConstructorInfo constructorInfo = objectType.GetConstructor(new[]
{
typeof(ScnObjectInfo),
typeof(ScnSceneInfo)
});
if (constructorInfo == null) throw ...
-
这是一个运行时编译检查 (Runtime Compilation Check)。
-
为什么需要?
因为后面的 Activator.CreateInstance 依赖于特定的构造函数签名。
如果你的 NpcObject 只有一个无参构造函数 public NpcObject() { },反射创建时就会失败。
这段代码强制要求所有子类必须实现 public ClassName(ScnObjectInfo info, ScnSceneInfo scene)。 -
这是一种非常防御性的编程风格,极大地减少了运行时 Crash 的概率。
4. 创建实例:Create 方法
public SceneObject Create(ScnObjectInfo objectInfo, ScnSceneInfo sceneInfo)
{
// ...
if (_sceneObjectTypeCache.TryGetValue(objectInfo.Type, out Type type))
{
return Activator.CreateInstance(type, objectInfo, sceneInfo) as SceneObject;
}
return null;
}
-
流程:
-
.scn 解析器读到一个物体,类型是 15 (假设是宝箱)。
-
调用 Create(info, scene)。
-
查表:15 -> ChestObject。
-
Activator 动态 new 出一个 ChestObject。
-
-
Activator.CreateInstance 仅仅是在 .NET 托管堆(Managed Heap) 中分配了一块内存,并调用了该类的构造函数。它生成的 SceneObject 是一个纯 C# 对象(Pure C# Object)(只是纯 C# 的内存分配,线程安全)
CommandExecutorRegistry<TCommand>
这是命令处理者的注册中心,在每一个命令处理类ICommandExecutor<T>的构造函数的最后都要使用注册中心进行注册。
类似于ServiceLoacator,在要使用处理类的时候也是通过这个注册中心进行Get()
之所以取消勾选 Navmeshes 相关的碰撞就能让角色移动,主要有以下三个深层原因:
1. 消除“自硬体冲突” (Resolving Self-Collision)
术语:物理遮蔽 (Physical Shadowing / Interference)
-
底层逻辑:在 Pal3.Unity 的架构中,Navmeshes 层通常包含了从 .nav 文件生成的“可行走区域”网格。
-
问题点:如果 Player 层和 Navmeshes 层在矩阵中是勾选(开启碰撞)的,Unity 的 PhysX 引擎会将这些导航网格视为实心的物理障碍物。
-
现象:当景天尝试向前走时,物理引擎检测到他正踩在一个“障碍物”上(因为脚部碰撞体和地面网格重叠了),于是为了防止“穿模”,物理引擎会产生一个反向的推力或直接锁定位移。
-
解决:取消勾选后,景天和导航网格在物理上变为了“幽灵”关系,他可以自由穿过这些网格,移动不再受物理引擎的阻挡。
2. 绕过了无效的“最近点”计算 (Bypassing Invalid Queries)
这直接解释了为什么之前的 ClosestPoint 报错消失了。
-
底层行为:Unity 的许多物理 API(包括 ClosestPoint 和内部的 Overlap 检查)都会参考 Layer Collision Matrix。
-
逻辑链条:
-
代码执行 IsNewPositionInsideCollisionCollider。
-
如果矩阵是勾选的,该查询会扫描到 Navmeshes 层的复杂网格。
-
程序尝试计算这个点到这个复杂凹网格(Concave Mesh)的距离
触发报错。→→
-
-
解决:当你取消勾选,物理引擎在预筛选阶段就直接把 Navmeshes 过滤掉了。查询根本不会触及这些复杂的网格,也就不会触发那个由于“不支持非凸网格”导致的崩溃。
3. 《仙三》复刻版的“双轨制”移动逻辑
这是一个非常高级的架构设计:逻辑归逻辑,物理归物理。
-
导航层 (Navmeshes):它的任务是提供 数据。比如 A* 寻路、高度计算(Y轴)。它应该是**“感官层面”**的,而不是“实体层面”的。
-
物理层 (Environment):它的任务是提供 阻挡。比如墙壁、大树。
-
为何奏效:在原作者的设计中,角色的 Y 轴(高度)通常是手动根据地砖数据设置的,而左右移动的阻挡是靠 Raycast 探测的。
-
如果你开启了物理矩阵碰撞,你就引入了第三种力(PhysX 刚体排斥力),这会和作者写的位移代码产生逻辑冲突(Fighting)。
-
取消勾选后,物理引擎“闭嘴”了,把控制权完全交还给了你的 C# 代码。
-
4. C++ 程序员的技术对照表
在底层 C++ 物理库(如 PhysX 或 Bullet)中,这对应于 位掩码过滤(Bitmask Filtering):
| Unity 操作 | C++ / PhysX 对应实现 | 作用 |
| 取消 Matrix 勾选 | filterData.word0 (Group) / word1 (Mask) | 告诉 Broad-phase 阶段直接丢弃该碰撞对。 |
| Layer 划分 | PxFilterObjectAttributes | 对场景中的对象进行分类标识。 |
| 射线检测忽略层 | PxQueryFilterData | 在执行射线/球体扫描时跳过特定组。 |
5. 针对实习面试的专业总结
面试官问:“你是如何处理复杂场景中的物理性能和逻辑冲突的?”
专业回答:
“在复刻《仙三》的移动系统时,我利用 Layer Collision Matrix 实现了 ‘职责分离的物理过滤’。
消除竞态:我将导航辅助网格(Navmesh Data)与实体碰撞层分离,通过在矩阵中禁用它们的物理交互,解决了运动学位移与 PhysX 动力学排斥力之间的逻辑冲突。
优化查询:这种配置能让物理引擎在 广义阶段(Broad-phase) 过滤掉不必要的非凸网格采样,从底层规避了 ClosestPoint 等 API 对 Concave Mesh 的计算限制。
性能增益:通过减少每一帧不必要的 碰撞流形(Collision Manifold) 计算,显著降低了物理模拟的 CPU 开销,确保了角色在大规模复杂建筑场景中移动的流畅度。”
总结建议
-
Navmesh 只有被代码读取的权利,没有在物理世界撞人的权力。
更多推荐


所有评论(0)