场景加载、地图装配与相机跟随模块 private
场景加载、地图装配与相机跟随模块
本篇回答三个容易混淆的问题:Unity 场景什么时候加载、战斗地图怎样装配、相机怎样跟随主角。最后给出一套可以迁移到其他 Unity 项目的实现模板。
1. 先给结论
QMGame 有两种不同的“场景”概念:
| 概念 | 当前项目的实际含义 | 主要入口 |
|---|---|---|
| Unity Scene | 启动、UI、大厅等长期存在或独立加载的 Unity 场景 | AddressablesX.LoadSceneAsync、SceneManager.LoadSceneAsync |
| 关卡/地图 | 一份由地图编辑器导出的二进制 TextAsset,运行时解析成 Entitas MapEntity | MapChangeLevelBySeedEvent、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.csAssets/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/HostClientLoadRandomMapSystemLoadCertainMapSystem/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.csAssets/Code/Game/NetworkSystems/HostClientLoadCertainMapSystem.csAssets/Code/Game/OServices/View/Map/MapProcessor.cs
3.3 主机与普通客户端的区别
主机客户端会调用 MapProcessor 解析地图,并选择随机组;随后通过 MapLoadedEvent 把关卡 GUID、模板索引和随机种子广播给其他客户端。普通客户端使用 HostClientLoadCertainMapSystem,按收到的索引重建,不再自行随机。
主机随机选择
-> 广播结果
-> 所有客户端使用相同的模板索引
-> 场景物件布局一致
3.4 View 系统装配地图
MapProcessor 只创建数据实体,真正实例化 Unity 对象由 View 系统完成:
| MapEntity 组件 | View 系统 | 运行时对象 |
|---|---|---|
Prop | MapPropAssetSystem | 地图装饰、碰撞、Renderer |
NavMesh/NavPortal | MapNavAssetsSystem | NavMesh prefab、NavMeshLink |
LevelLightmap | LoadLightmapTexturesSystem | LightmapSettings.lightmaps |
TemplateProps + ElementEnabled | MapPropAssetSystem | 按玩家范围加载/回收物件 |
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.csAssets/Code/Game/Map/DefaultMapLoadingController.csAssets/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
关键代码:
ClientConnectionResponseSystem.OnReceiveHostInfo写入SpawnHeroEvent。SpawnHeroSystem.Spawn克隆出生点 blueprint,叠加@event.Offset,设置 HeroType、Level、PlayerId。SetMainHeroSystem监听 GameObjectEntity 创建,按entity.playerId发布MainHeroCreated、RemoteHeroCreated或DummyHeroCreated。CameraManager.OnMainHeroCreated保存主角 CoreEntity,并找到对应的Hero.transform。
出生点不是 Camera 的职责。出生点来自地图导出的 HeroSpawner/SpawnHeroEvent;Camera 只消费已经创建好的主角对象。
代码:
Assets/Code/Game/OGame/Network/Systems/ClientConnectionResponseSystem.csAssets/Code/Game/OGame/Map/Systems/SpawnHeroSystem.csAssets/Code/Game/OLogic/Systems/SetMainHeroSystem.csAssets/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 | 攻击/受击/动画触发的震动 | 改变角色逻辑位置 |
CameraSetter | Editor 中按距离、俯仰、偏航摆放相机 | 运行时跟随 |
UICamera | ScreenSpaceCamera 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);
}
}
迁移时只替换四个项目相关适配点:
TargetProvider:本地玩家创建完成事件。SceneLoader:Addressables/AssetBundle/Resources 的加载实现。MapAssembler:地图数据到 Prefab/NavMesh/Lightmap 的装配实现。CameraConfig:PC/Mobile 偏移、追赶阈值、震动配置。
不要把网络协议、业务状态机、Animator 状态名写进通用 Camera Runtime;它们应通过事件或接口接入。
11. 排错顺序
11.1 地图黑屏或一直 Loading
- 是否收到
MapChangeLevelBySeedEvent或MapChangeLevelByIndicesEvent。 CfgAssetsPath.GetAssetPath(Guid)是否返回有效路径。TextAsset是否能从 AMF/AssetBundle 读取。MapProcessor是否创建了MapEntity。LightmapLoadedEvent是否发出。MapNavAssetsSystem是否加载了 NavMesh prefab。- Battle 模式的所有网络玩家是否都标记
Ready。
11.2 主角出现但相机不跟
- 是否收到
MainHeroCreated,且playerId等于本地玩家 ID。 CameraManager._cameraMap是否包含HeroFollowCamera。- 当前是否实际
SwitchCamera(HeroFollowCamera)。 _curCameraCtrl.LookAtTransform是否为主角 Transform。FollowConfig的StartDist是否过大,IsPause是否为 true。- 是否存在父节点坐标空间混用导致的
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 |