在管理游戏场景时使用了命令模式 (Command Pattern) 的一种变体:Command Dispatcher (命令分发器)

在传统的 OOP中,直接调用 SceneManager->LoadScene("m01") 。
但在现在的 C# 架构中,它被解耦了:

  1. 发送者 (可能是 UI 按钮、脚本解析器、触发器) 创建一个 SceneLoadCommand 数据包。

  2. 分发者 (CommandDispatcher) 找到谁能处理这个命令。

  3. 执行者 (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 需要场景名)。它的存在本身就是一个信号。当系统收到这个类型的对象时,就知道要执行重置逻辑。


完整的调用链:

  1. 开发者在游戏里按下 ~ 键打开控制台。

  2. 输入: ResetGameState 并回车。

  3. 控制台系统:

    • 检查 ResetGameStateCommand 类是否有 [AvailableInConsole] 标记? 有!

    • 创建 new ResetGameStateCommand() 实例。

    • 将实例扔给 CommandDispatcher。

  4. 分发器: 找到 SceneManager 是该命令的执行者。

  5. 执行: 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# 对象。

  • 流程:

    1. 解析阶段: ISceCommandParser 读取 .sce 文件,读到 158。

    2. 反射查找: 系统查表发现 158 对应 SceneObjectDoNotLoadFromSaveStateCommand。

    3. 参数读取: 解析器继续读取后面的 4 个字节,作为 objectId。

    4. 实例化: new SceneObjectDoNotLoadFromSaveStateCommand(100)。

    5. 分发: 扔给 CommandDispatcher。

    6. 执行: 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()获取当前程序集下的所有类型。

  • 筛选条件:

    1. 必须是 class(不是接口或结构体)。

    2. 必须继承自 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;
}
  • 流程:

    1. .scn 解析器读到一个物体,类型是 15 (假设是宝箱)。

    2. 调用 Create(info, scene)。

    3. 查表:15 -> ChestObject。

    4. 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

  • 逻辑链条

    1. 代码执行 IsNewPositionInsideCollisionCollider。

    2. 如果矩阵是勾选的,该查询会扫描到 Navmeshes 层的复杂网格。

    3. 程序尝试计算这个点到这个复杂凹网格(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 实现了 ‘职责分离的物理过滤’

  1. 消除竞态:我将导航辅助网格(Navmesh Data)与实体碰撞层分离,通过在矩阵中禁用它们的物理交互,解决了运动学位移与 PhysX 动力学排斥力之间的逻辑冲突。

  2. 优化查询:这种配置能让物理引擎在 广义阶段(Broad-phase) 过滤掉不必要的非凸网格采样,从底层规避了 ClosestPoint 等 API 对 Concave Mesh 的计算限制。

  3. 性能增益:通过减少每一帧不必要的 碰撞流形(Collision Manifold) 计算,显著降低了物理模拟的 CPU 开销,确保了角色在大规模复杂建筑场景中移动的流畅度。”

总结建议

  • Navmesh 只有被代码读取的权利,没有在物理世界撞人的权力。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐