场景加载、地图装配与相机跟随模块 private

场景加载、地图装配与相机跟随模块

本篇回答三个容易混淆的问题:Unity 场景什么时候加载、战斗地图怎样装配、相机怎样跟随主角。最后给出一套可以迁移到其他 Unity 项目的实现模板。

返回技术文档目录

1. 先给结论

QMGame 有两种不同的“场景”概念:

概念当前项目的实际含义主要入口
Unity Scene启动、UI、大厅等长期存在或独立加载的 Unity 场景AddressablesX.LoadSceneAsync、SceneManager.LoadSceneAsync
关卡/地图一份由地图编辑器导出的二进制 TextAsset,运行时解析成 Entitas MapEntityMapChangeLevelBySeedEvent、MapChangeLevelByIndicesEvent

战斗中通常不是每换一关就调用 SceneManager.LoadScene。Battle/Story/GVG 的地图切换主要是:

收到关卡 GUID/随机种子/模板索引
        -> 读取地图 TextAsset
        -> MapProcessor 解析地图元素
        -> MapEntity 触发 Prop/NavMesh/Lightmap 等 View 系统
        -> 创建玩家、怪物、场景物件
        -> 等待本地资源和联机玩家 Ready
        -> 关闭 Loading UI,进入游戏

战斗相机也不是把 Camera GameObject 直接 SetParent(hero.transform)。它保留一个 LookAtTransform 引用,由跟随效应器在 LateUpdate 中按照偏移和追赶参数移动。

2. Unity 场景的加载边界

2.1 启动阶段的 UI Scene

ClientProxy 在客户端初始化阶段加载 UI 场景,并使用 Additive 模式保留启动场景:

await AddressablesX.LoadSceneAsync("UI", LoadSceneMode.Additive);

代码:Assets/Code/ClientProxy.cs。

这一步提供 UI Camera、Canvas、全局 UI 管理器等常驻对象。它和战斗地图数据不是同一条加载链路。

2.2 大厅 Scene

LobbySceneController 根据进入来源加载 Lobby:

await AddressablesX.LoadSceneAsync("Lobby", LoadSceneMode.Additive);
// 从战斗返回的兼容路径仍使用 SceneManager.LoadSceneAsync
await SceneManager.LoadSceneAsync(LOBBY_SCENE_NAME, LoadSceneMode.Additive);

离开大厅时通过 SceneManager.UnloadSceneAsync 卸载。大厅拥有独立的 Lobby Camera,DefaultLobbyLoadingController 在初始化时切换到 CameraType.LobbyCamera。

代码:

  • Assets/Code/Game/OLogic/Lobby/LobbySceneController.cs
  • Assets/Code/Game/Map/DefaultLobbyLoadingController.cs

2.3 战斗不是一个独立 Unity Scene 切换

战斗入口由 MapAdapter.ChangeMapLoadingController 选择加载策略:

MapLoadingTypeEnum.NORMAL   -> DefaultMapLoadingController
MapLoadingTypeEnum.BATTLE   -> BattleMapLoadingController
MapLoadingTypeEnum.STORY    -> StoryMapLoadingController
MapLoadingTypeEnum.GUILDWAR -> GVGMapLoadingController

代码:Assets/Code/Game/Map/MapAdapter.cs。

这些 Controller 监听 MapContext 的关卡事件,而不是监听 Unity SceneManager 的 sceneLoaded 回调。

3. 地图数据怎样进入运行时

3.1 关卡事件

地图切换有两种事件:

  • MapChangeLevelBySeedEvent:给定关卡 GUID 和随机种子,由主机客户端按种子选择随机模板。
  • MapChangeLevelByIndicesEvent:给定关卡 GUID 和模板索引,客户端按服务端/主机已经确定的索引重建同一张地图。

触发器分别是:

  • LoadRandomMapSystem / HostClientLoadRandomMapSystem
  • LoadCertainMapSystem / HostClientLoadCertainMapSystem

3.2 读取地图资产

系统先用关卡 GUID 查询资产路径,再从 AssetBundle/AMF 读取 TextAsset:

var assetPath = Database.CfgAssetsPath.GetAssetPath(level.LevelId);
var mapTextAsset = AMF.AssetManager.Instance.GetAsset<TextAsset>(
    assetPath, AMF.ASSETTYPE.TEXT);

随后 MapProcessor.Process(mapTextAsset) 反序列化地图文件,并把元素变成 Entitas MapEntity。地图文件中的元素包括 Template/Prop/PaintedProp、NavMesh/NavPortal、SpotLight/PointLight、SpawnPoint、MiniMap、ColliderTrigger、随机组和玩家出生随机组。

代码:

  • Assets/Code/Game/NetworkSystems/HostClientLoadRandomMapSystem.cs
  • Assets/Code/Game/NetworkSystems/HostClientLoadCertainMapSystem.cs
  • Assets/Code/Game/OServices/View/Map/MapProcessor.cs

3.3 主机与普通客户端的区别

主机客户端会调用 MapProcessor 解析地图,并选择随机组;随后通过 MapLoadedEvent 把关卡 GUID、模板索引和随机种子广播给其他客户端。普通客户端使用 HostClientLoadCertainMapSystem,按收到的索引重建,不再自行随机。

主机随机选择
    -> 广播结果
    -> 所有客户端使用相同的模板索引
    -> 场景物件布局一致

3.4 View 系统装配地图

MapProcessor 只创建数据实体,真正实例化 Unity 对象由 View 系统完成:

MapEntity 组件View 系统运行时对象
PropMapPropAssetSystem地图装饰、碰撞、Renderer
NavMesh/NavPortalMapNavAssetsSystemNavMesh prefab、NavMeshLink
LevelLightmapLoadLightmapTexturesSystemLightmapSettings.lightmaps
TemplateProps + ElementEnabledMapPropAssetSystem按玩家范围加载/回收物件

NavMesh 由 MapNavObjectFactory 按 GUID 查找 prefab,挂到 __NavMesh__ 根节点;导航连接通过 NavMeshLink 参数恢复。光照系统读取 LevelLightmap 的纹理列表和属性文件,异步生成 LightmapData[],完成后写入 LightmapSettings.lightmaps。

4. Loading UI 何时才算结束

四类地图 Loading Controller 的共同条件是:

收到 MapChangeLevel 事件
    -> 开始 Loading UI、隐藏游戏 UI
    -> 等待 LightmapLoadedEvent
    -> 等待 NavMesh 组件/资源就绪
    -> 普通模式:等待必要剧情对话结束
    -> Battle/GVG:等待所有玩家 Ready
    -> 延迟至少一段时间后 Fade
    -> FinishLoadingGameLevel

战斗模式的进度来自:

m_progress = (float)m_playerEntities_Ready.count /
             (float)m_playerEntities_All.count;

主角 GameObject 创建后,MainHeroCreated 会把本地网络实体标记为 Ready。因此“地图资源已加载”不等于“联机加载完成”;还要等所有玩家报告 Ready。

代码:

  • Assets/Code/Game/Map/BattleMapLoadingController.cs
  • Assets/Code/Game/Map/DefaultMapLoadingController.cs
  • Assets/Code/Game/OLogic/Systems/SetMainHeroSystem.cs

5. 玩家创建和相机绑定发生在哪里

玩家链路是:

HostInfo/本地出生配置
    -> SpawnHeroEvent(HeroType, Offset, Level)
    -> SpawnHeroSystem 读取 HeroSpawner + SpawnHeroEvent
    -> 创建 CoreEntity Hero
    -> ObjectAssetSystem 异步创建角色 Prefab
    -> SetMainHeroSystem 按 playerId 判断本地/远端
    -> MainHeroCreated
    -> CameraManager 绑定 Hero Transform

关键代码:

  1. ClientConnectionResponseSystem.OnReceiveHostInfo 写入 SpawnHeroEvent。
  2. SpawnHeroSystem.Spawn 克隆出生点 blueprint,叠加 @event.Offset,设置 HeroType、Level、PlayerId。
  3. SetMainHeroSystem 监听 GameObjectEntity 创建,按 entity.playerId 发布 MainHeroCreated、RemoteHeroCreated 或 DummyHeroCreated。
  4. CameraManager.OnMainHeroCreated 保存主角 CoreEntity,并找到对应的 Hero.transform。

出生点不是 Camera 的职责。出生点来自地图导出的 HeroSpawner/SpawnHeroEvent;Camera 只消费已经创建好的主角对象。

代码:

  • Assets/Code/Game/OGame/Network/Systems/ClientConnectionResponseSystem.cs
  • Assets/Code/Game/OGame/Map/Systems/SpawnHeroSystem.cs
  • Assets/Code/Game/OLogic/Systems/SetMainHeroSystem.cs
  • Assets/Code/Game/OLogic/Manager/CameraManager.cs

6. 相机对象和控制器结构

6.1 CameraManager:相机总开关

CameraManager 挂在场景中的相机管理对象上,序列化保存 _cameraMap:

public enum CameraType
{
    LobbyCamera = 0,
    HeroFollowCamera,
    Count
}

SwitchCamera 做四件事:禁用旧 CameraController、激活目标 CameraController、把当前 Camera 交给 UI 投影工具并应用屏幕缩放、在 HeroFollowCamera 模式重新绑定主角并 ResetCamera()。

6.2 CameraController:通用基类

CameraController 保存 PC/Mobile 两套偏移和旋转、_lookAtTransform 目标引用以及场景上的 Unity Camera 组件。ResetCamera() 直接把相机放到:

transform.rotation = Quaternion.Euler(Rotation);
transform.position = _lookAtTransform.position + DesiredOffset;

6.3 InGameCameraController:战斗相机组合器

战斗相机在 Awake() 中创建两个效应器:

InGameCameraController
    ├── CameraFollowEffector
    └── CameraShakeEffector

在 LateUpdate 中,CameraManager 调用当前控制器的 MoveCamera();InGameCameraController.MoveCamera() 逐个 Tick 未完成的效应器。Hero/MoveController 修改角色真实位置,FollowEffector 产生相机位置,ShakeEffector 临时叠加镜头震动。

7. 相机是否“挂在角色身上”

准确答案:逻辑上跟随,Transform 层级上不挂载。

绑定发生在:

_curCameraCtrl.SetLookAtTransform(hero.transform);
_curCameraCtrl.ResetCamera();

跟随发生在 CameraFollowEffector.Tick():

_targetLastPos = _lookAtTransform.position + _desiredOffset;

if (_sqrDistNow >= _followConfig.StartDist * _followConfig.StartDist)
    isStartMoving = true;

Vector3 finalPos = Vector3.Lerp(
    _cameraTransform.localPosition,
    _targetLastPos,
    smoothDur * Time.deltaTime);
_cameraTransform.localPosition = finalPos;

当角色移动距离超过 StartDist 才启动追赶;超过 ExceedDist 时使用更快的 ExceedDur。主角停止并且距离足够近后停止追赶。

这使相机可以独立重置、叠加震动、切换剧情目标,并避免 UI Camera 跟随玩家 Transform。

7.1 迁移时的坐标空间检查

当前 CameraFollowEffector 用 _cameraTransform.localPosition 和 _lookAtTransform.position + offset 计算目标,存在父节点坐标空间混用的风险。迁移到新项目时应统一使用世界坐标,或明确 CameraController 父节点的坐标约定:

var targetWorldPos = _lookAtTransform.position + _desiredOffset;
_cameraTransform.position = Vector3.Lerp(
    _cameraTransform.position,
    targetWorldPos,
    catchup * Time.deltaTime);

不要在没有确认父节点语义前直接复制原实现。

8. 相机控制脚本职责表

脚本职责不应该负责
CameraManager相机选择、启停、主角绑定、消息入口计算角色移动
CameraController偏移、旋转、目标引用、重置业务状态和技能逻辑
InGameCameraController组合跟随/震动效应器、FOV创建角色
CameraFollowEffector追赶、阻尼、停止条件改变主角 Transform
CameraShakeEffector攻击/受击/动画触发的震动改变角色逻辑位置
CameraSetterEditor 中按距离、俯仰、偏航摆放相机运行时跟随
UICameraScreenSpaceCamera UI 投影3D 世界相机跟随

9. 相机震动和网络边界

技能或动画事件发送 ShakeCamera,CameraManager.OnStartShakeCamera 把震动类型交给 CameraShakeEffector。效应器根据 ShakeConfig 的 Duration/Magnitude 在 Tick 中修改相机位置,结束后恢复 _originPos。停止震动通过 StopShakeCamera 统一处理。

同步的是技能/事件状态,不是相机每一帧的位置;远端玩家不会因为收到普通技能状态就复制本地相机震动,镜头反馈属于各客户端本地表现。

10. 可迁移到其他项目的最小实现

下面是与 QMGame 无关的通用边界:

public interface ICameraTargetProvider
{
    Transform CurrentTarget { get; }
    event Action<Transform> TargetChanged;
}

public interface ICameraEffect
{
    void Begin(CameraContext context);
    void Tick(float deltaTime);
    void End();
}

public sealed class FollowCameraRuntime
{
    readonly List<ICameraEffect> _effects;

    public void SetTarget(Transform target)
    {
        // 只替换引用,不改变角色与相机的层级关系。
    }

    public void LateTick(float deltaTime)
    {
        foreach (var effect in _effects)
            effect.Tick(deltaTime);
    }
}

迁移时只替换四个项目相关适配点:

  1. TargetProvider:本地玩家创建完成事件。
  2. SceneLoader:Addressables/AssetBundle/Resources 的加载实现。
  3. MapAssembler:地图数据到 Prefab/NavMesh/Lightmap 的装配实现。
  4. CameraConfig:PC/Mobile 偏移、追赶阈值、震动配置。

不要把网络协议、业务状态机、Animator 状态名写进通用 Camera Runtime;它们应通过事件或接口接入。

11. 排错顺序

11.1 地图黑屏或一直 Loading

  1. 是否收到 MapChangeLevelBySeedEvent 或 MapChangeLevelByIndicesEvent。
  2. CfgAssetsPath.GetAssetPath(Guid) 是否返回有效路径。
  3. TextAsset 是否能从 AMF/AssetBundle 读取。
  4. MapProcessor 是否创建了 MapEntity。
  5. LightmapLoadedEvent 是否发出。
  6. MapNavAssetsSystem 是否加载了 NavMesh prefab。
  7. Battle 模式的所有网络玩家是否都标记 Ready。

11.2 主角出现但相机不跟

  1. 是否收到 MainHeroCreated,且 playerId 等于本地玩家 ID。
  2. CameraManager._cameraMap 是否包含 HeroFollowCamera。
  3. 当前是否实际 SwitchCamera(HeroFollowCamera)。
  4. _curCameraCtrl.LookAtTransform 是否为主角 Transform。
  5. FollowConfig 的 StartDist 是否过大,IsPause 是否为 true。
  6. 是否存在父节点坐标空间混用导致的 localPosition/position 偏差。

11.3 相机跟随但画面抖动

检查 Follow 和 Shake 是否同时写相机 Transform、是否有多个 CameraController 同时 active、角色是否被 NavMeshAgent/同步纠偏/MoveController 同时改位置、ResetCamera 是否被每帧误调用,以及相机是否在 LateUpdate 执行。

12. 源码索引

主题文件
Unity UI Scene 加载Assets/Code/ClientProxy.cs
Lobby Scene 加载/卸载Assets/Code/Game/OLogic/Lobby/LobbySceneController.cs
加载策略选择Assets/Code/Game/Map/MapAdapter.cs
战斗 Loading 条件Assets/Code/Game/Map/BattleMapLoadingController.cs
地图解析Assets/Code/Game/OServices/View/Map/MapProcessor.cs
地图物件流式加载Assets/Code/Game/OGame/Map/Systems/MapPropAssetSystem.cs
NavMesh 装配Assets/Code/Game/OGame/Map/Systems/MapNavAssetsSystem.cs、Assets/Code/Game/OServices/View/Map/Navigation/MapNavObjectFactory.cs
Lightmap 装配Assets/Code/Game/OServices/View/Map/Light/LoadLightmapTexturesSystem.cs
玩家出生Assets/Code/Game/OGame/Map/Systems/SpawnHeroSystem.cs
主角识别Assets/Code/Game/OLogic/Systems/SetMainHeroSystem.cs
相机总管理Assets/Code/Game/OLogic/Manager/CameraManager.cs
相机基类和重置Assets/Code/Game/OLogic/Camera/CameraController.cs
战斗相机组合Assets/Code/Game/OLogic/Camera/InGameCameraController.cs
跟随算法Assets/Code/Game/OLogic/Camera/CameraFollowEffector.cs
震动算法Assets/Code/Game/OLogic/Camera/CameraShakeEffector.cs
Editor 摆位工具Assets/Code/Game/OLogic/Camera/CameraSetter.cs