主题
场景系统(SceneSystem)
SCE 场景是框架管理的地图/逻辑空间容器,用于承载单位、实体、场景资产和空间查询。使用 SCE Unit/技能系统并需要地图空间的游戏通常至少需要一个场景;直接 GameGraph SceneGraph 游戏或纯 Canvas/GameUI 游戏不一定需要 SCE 场景。
> 架构路线选择见 游戏架构路线、SCE 场景空间模型与 Canvas/GameGraph 边界。创建 3D 项目/场景、选择 SpaceModel、生成 3D 体素/导航/碰撞产物的编辑器流程,见 3D 场景创建、SpaceModel 与场景产物。本文档主要说明运行时代码中的场景系统 API。
核心概念
HostedSceneTag 与场景资产
每个场景都关联一个 HostedSceneTag,对应编辑器中创建的地形资产(scene/ 目录下的文件)。
关键约束: HostedSceneTag 必须匹配项目中已有的场景资产。场景资产由地形编辑器生成,不可在代码中凭空创建。
每个项目模板都自带一个默认场景 new_scene(4096×4096),对应 scene/new_scene/ 目录下的资产文件。
GameDataScene
GameDataScene 是场景的数据定义,关联场景资产、尺寸、相机和预放置对象。
csharp
// 项目中已有的场景数据(由编辑器自动生成,位于 DataGenerated/ 中)
// 通过 ScopeData.GameDataScene.new_scene 访问GameDataScene 的关键属性:
| 属性 | 类型 | 说明 |
|---|---|---|
HostedSceneTag | HostedSceneTag | 必填。场景资产标识,必须对应已有地形文件 |
Size | Vector2 | 场景尺寸(世界单位),须为 256 的倍数,并与地形资产尺寸一致。地形编辑器属性面板按 1~128 格显示,保存时每格换算为 256 世界单位 |
DefaultCamera | IGameLink<GameDataCamera> | 客户端加载时使用的默认相机 |
RenderScenery | bool | 是否渲染地形/装饰 |
SpaceModel | SceneSpaceModel | 场景空间模型,决定按 2.5D 单层地表还是真 3D 多层空间处理寻路、单位高度与碰撞;不决定渲染维度 |
Vision | SceneVisionConfig? | 场景级视野配置,支持正常视野、视野全开和精确视野。详见 场景视野系统 |
PlacedSceneShapes | QuickDictionary | 预放置的形状(区域、点、线) |
PlacedPlayerObjects | QuickDictionary | 预放置的单位/物品 |
SpaceModel 不是渲染维度开关
GameDataScene.SpaceModel 描述的是 寻路、单位高度和碰撞的空间解释方式,不是“这个场景是不是 2D 画面”的开关。
SceneSpaceModel.Surface2D:2.5D 单层地表寻路。导航按 XY 平面上的单层可走面处理,但 SCE 场景仍然可以有地形起伏、高台、坡面和 Z 高度;它不是 Canvas/纯 2D 场景,也不表示场景没有高度。SceneSpaceModel.Volumetric3D:真 3D 多层空间。用于同一 XY 位置可能存在桥、隧道、上下楼层等多层可走面的场景,依赖 3D 导航/体素/阻挡产物。
判断是否需要 Volumetric3D 时,看玩法是否需要同一水平位置存在多层可走面,而不是看画面是不是 3D。普通有高度起伏的地形、坡道或单层高台通常仍然属于 Surface2D。
编辑器中的场景属性
地形编辑器右侧属性面板可以直接维护场景名、尺寸、SpaceModel、Vision、是否渲染地形环境,以及氛围和可放置形状等常用配置。场景名会写入 GameDataScene.Name,尺寸会按“格数 × 256”保存到 GameDataScene.Size,可放置形状会保存到 GameDataScene.PlacedSceneShapes。详见 地形编辑器场景属性面板。
多场景共用资产(逻辑场景)
多个 GameDataScene 可以共享同一个地形资产,通过 HostedSceneTag 的双参数构造函数实现:
csharp
// 创建一个逻辑场景,使用 new_scene 的地形资产但有独立标识
HostedSceneTag = new HostedSceneTag("arena_instance_1"u8, "new_scene"u8)逻辑场景的 Scene.IsLogicScene 为 true。
运行时创建 SCE 场景副本
如果一个玩法需要为不同玩家、房间或波次创建彼此隔离的地图实例,可以在服务端从已有 Scene 创建运行时副本:
csharp
#if SERVER
var template = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
var copy = template.CreateCopy($"dungeon_{player.Id}");
copy.OnLoadEnd += () => player.SwitchScene(copy);
copy.Load();
#endifScene.CreateCopy 也接受 IGameLink<GameDataScene> 或 GameDataScene。名称为空时框架生成唯一名称;显式名称必须先通过场景唯一性检查,重复名称会抛出异常。该 API 只能在服务端默认场景初始化完成后调用,复制失败会返回异常而不会产生半初始化的场景对象。
副本的素材来源取自源场景的 HostedSceneTag.AssetTemplate。客户端收到副本元数据后,会在 C# 层关联源 GameDataScene,因此既有镜头查询仍从 scene.Cache.DefaultCamera 读取源场景的默认镜头,不需要修改客户端二进制或为副本新增镜头配置。副本自己的名称、hash 和运行时 HostedSceneTag 独立保存,不会使用源 Cache.HostedSceneTag.SceneTag 作为副本身份。元数据关联完成时会触发 Scene.OnReplicated 和 EventSceneReplicated。
动态副本只复制 native 场景素材,不复制 GameDataScene 的预放置单位、物品、区域或脚本对象。请在副本加载完成后按玩法重新创建这些运行时对象。它也不等同于直接创建和发布独立的 GameGraph SceneGraph;后者仍由脚本自行管理生命周期和发布范围。
携带已有英雄跨场景
玩家当前场景、英雄所在场景和主控绑定是三个相关状态。切换玩家视图不等于迁移英雄,服务端迁移成功也不等于客户端已加载地图、建立英雄副本并恢复操控。
选择迁移职责
| 路线 | 适用方式与边界 |
|---|---|
player.SwitchScene(target, withMainUnit: true) | 让框架携带当前 MainUnit 切换场景;适合委托框架迁移现有主控,不指定英雄落点。仍需验证英雄、库存和客户端最终状态 |
hero.MoveTo(targetPoint),再 player.SwitchScene(target, withMainUnit: false) | 显式迁移原英雄并指定落点,再切换玩家视图和重新绑定;适合传送门、副本往返和需要明确恢复步骤的流程 |
同一次迁移选定一种职责划分。已经显式迁移英雄后,使用 withMainUnit: false 切换玩家视图,避免再要求框架携带主控。SwitchScene 返回 true 不是客户端完成通知,也不能作为英雄、库存和主控全部迁移成功的证明。
显式迁移原英雄的顺序
以下步骤适用于保留原英雄及其库存的服务端流程。目标场景需要先完成服务端加载;客户端加载另行验收。
- 保存原英雄引用及
EntityId、原玩家槽位player.Id、原场景和位置、原主控绑定,以及待校验的库存物品身份。对同一玩家的迁移请求做互斥或去重,防止重复触发交错执行。 - 调用
hero.MoveTo(targetPoint)并检查返回值。目标点属于不同场景时,该调用会先尝试迁移实体,再设置位置;失败时也要读取实际状态,不能假定此前步骤完全没有生效。当前框架会在 Unit 切场景后尝试递归迁移库存中的物品单元,项目应验证结果,无需逐件调用内部迁移接口。 - 核对原英雄仍有效、
EntityId与所属Player未变、位置及Scene正确;通过target.GetUnit(hero.EntityId)和原场景查询核对登记已转移。核对库存仍属于原英雄、物品单元已进入目标场景。场景登记由框架维护,项目不手工修改集合。 - 调用
player.SwitchScene(target, withMainUnit: false)并检查返回值与player.Scene。 - 确认服务端
player.Scene == hero.Scene后,设置player.MainUnit = hero恢复原英雄控制权;即使托管属性看起来仍指向原英雄,也要核对切场景后的实际控制绑定。不同场景时不要提前绑定。 - 客户端等待目标场景加载完成,并确认本地英雄副本和
Player.LocalPlayer.MainUnit就绪,才恢复依赖英雄的界面与操作。用同步 ID 对齐服务端原英雄与客户端副本;不同端的托管引用不能直接比较。OnLoadEnd只证明地图加载完成,不能单独证明英雄副本和主控已就绪。等待应有超时与失败处理。
失败后的恢复
迁移包含多步操作,不保证自动原子回滚。任一步失败后,停止继续绑定或提交成功,记录失败阶段和双方实际状态。原英雄有效且原场景仍可用时,沿相同职责顺序恢复:将原英雄移回保存的位置并核对库存、场景登记和原槽位,再以 SwitchScene(originalScene, false) 恢复玩家视图,确认双方同场后恢复保存的主控绑定。
每一步恢复也要检查结果,并验收客户端恢复。原场景已卸载、原英雄已销毁或恢复失败时,按项目定义保留加载/错误提示并报告失败,不继续宣布恢复成功,也不默认重建英雄掩盖原对象丢失。需要支持返回时,应保留原场景直到迁移结果确认。
双端验收
在真实服务端调试局中,记录同一局的玩家槽位、英雄同步 ID、源/目标场景、各步结果和客户端就绪状态,结合 预览与验收 Skill 获取证据。至少覆盖进入目标场景、返回原场景、连续往返或重复触发,以及一次受控失败与恢复。
- 服务端:原英雄身份、有效性、所属玩家、场景登记、落点及库存归属正确,玩家与英雄同场且
MainUnit指向预期英雄。 - 客户端:本地玩家关联目标场景且地图已加载,英雄副本和主控同步 ID 对应原英雄,血蓝及技能显示正确,镜头跟随符合配置,并能实际移动、施法和使用库存。客户端副本可以重建,不能要求跨场景保持同一个客户端托管引用。
- 出现蓝底、血蓝显示“—”、技能栏空或镜头失去目标时,分别检查地图加载、英雄复制、主控绑定和 UI 数据来源;这些症状不足以单独确认根因。若只取得服务端证据,应报告“服务端已验证,客户端待验收”。
AI 处理此类问题时,先读 角色跨场景迁移 Skill。
SCE 场景与 GameGraph SceneGraph
如果还需要同时理解单位、表现和 Node 组件的关系,先查GameCore 与 GameGraph 桥接;本节集中说明场景访问与生命周期。
SCE 场景(GameCore.SceneSystem.Scene)是框架管理的数编地图/逻辑场景;GameGraph SceneGraph 是底层 Urho Scene 的脚本包装。两者有关联,但生命周期归属不同。
普通客户端/服务端流程中,同一客户端同一时刻最多绑定一个当前 SCE 场景。这个当前场景由服务端场景系统加载和切换,客户端跟随服务端通知加载对应地图资源。脚本不需要为 SCE 场景手动 Publish / Unpublish,也不需要手动 Dispose 它的内置 SceneGraph。
如果要访问 SCE 场景内部的 GameGraph 包装,使用 GameGraph.NodeSystem 中的扩展方法:
csharp
using GameGraph.NodeSystem;
var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
scene.Load();
SceneGraph? sceneGraph = scene.GetSceneGraph();
Node? syncRoot = scene.GetSyncRoot();| API | 说明 |
|---|---|
scene.GetSceneGraph() | 获取该 SCE 场景底层 Urho Scene 的 SceneGraph 包装 |
scene.GetSyncRoot() | 获取当前 SCE 场景的同步挂载根 |
scene.AddSyncNode(node, NodeReplication.Replicated) | 把节点加入当前 SCE 场景同步路径 |
这些 API 在场景尚未加载或 native 场景尚未就绪时可能返回 null。需要稳定访问时,优先在 scene.OnLoadEnd 后执行,并做空值检查。
脚本创建的独立 new SceneGraph(...) 则不属于 SCE 场景系统。服务端可以创建任意多个 replicated SceneGraph,按玩家 Publish(player);同一个客户端可以在当前 SCE 场景之外,同时订阅多个这样的发布图。它们的生命周期由脚本负责,流程结束时应 Unpublish(...) 或 Dispose()。
场景加载流程
标准流程(服务端)
csharp
#if SERVER
// 1. 通过项目已有的 GameDataScene 获取场景实例
var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
// 2. 加载场景(触发 OnLoadStart → 创建预放置对象 → OnLoadEnd)
scene.Load();
// 3. 在场景中使用坐标
var center = new ScenePoint(2048, 2048, scene);
#endif纯客户端流程(客户端)
纯客户端调试或纯 GameGraph/表现预览没有服务端切场景通知。如果需要在客户端本地加载一个数编场景资产,使用 LoadClientOnly():
csharp
#if CLIENT
if (Game.IsClientOnly)
{
var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
scene.LoadClientOnly();
}
#endif如果只是加载当前游戏模式的默认场景,可以使用 Scene.LoadDefaultClientOnly():
csharp
#if CLIENT
if (Game.IsClientOnly)
{
Scene.LoadDefaultClientOnly();
}
#endifLoadClientOnly() 会在 native 客户端加载并切换当前场景,但不会创建服务端 Entity / Unit,也不会执行 Player.SwitchScene。LoadDefaultClientOnly() 读取当前 GameDataGameMode.DefaultScene 后转调 LoadClientOnly()。需要在场景加载完成后创建纯表现 Actor、GameGraph 节点或 UI 时,订阅 scene.OnLoadEnd。
如果一个游戏模式在纯客户端调试中总是需要默认场景,可以在 GameDataGameMode 上设置 AutoLoadDefaultSceneInClientOnly = true。该配置默认关闭,避免 UI、本地工具或低层 GameGraph 原型启动时产生不必要的地图加载成本。
Scene 获取方式
| 方法 | 返回值 | 说明 |
|---|---|---|
Scene.GetOrCreate(IGameLink<GameDataScene>) | Scene | 推荐。通过 GameLink 获取或创建 |
Scene.Get(IGameLink<GameDataScene>) | Scene? | 仅获取已存在的场景 |
Scene.GetOrCreate(ReadOnlySpan<byte>) | Scene | 通过名称获取或创建 |
Scene.Get(ReadOnlySpan<byte>) | Scene? | 通过名称获取 |
Scene.GetOrCreate(int, string?) | Scene | 通过 hash 获取或创建 |
场景生命周期事件
> GameStart 不等于客户端场景加载完成。 游戏脚本可能先开始运行,客户端随后才收到场景切换并加载地图。Game.LocalScene 在这段时间保持非空;若 IsEngineFallback 为 true,它只支持不依赖地图数据的 Actor 归属,不代表存在可用的地形、导航、场景碰撞、预放置对象或底层 SCE SceneGraph。依赖地图的代码应等待目标场景的 OnLoadEnd,不要用固定帧数或固定延迟推断加载完成。
csharp
#if SERVER
var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
scene.OnLoadStart += () => { /* 加载开始 */ };
scene.OnLoadEnd += () => { /* 加载完成,场景可用 */ };
scene.OnLoadPlacedObjects += () => { /* 预放置对象已创建 */ };
scene.Load();
#endif
#if CLIENT
var scene = Scene.GetOrCreate(ScopeData.GameDataScene.new_scene);
scene.OnLoadStart += () => { /* 客户端场景开始加载 */ };
scene.OnLoadEnd += () => { /* 客户端场景加载完成 */ };
if (Game.IsClientOnly)
{
scene.LoadClientOnly();
}
#endifOnLoadStart / OnLoadEnd 在客户端也可用,来源是 native 场景加载事件。OnLoadPlacedObjects 和服务端预放置对象创建只在服务端可用。
客户端 Scene.LoadState 会记录最近一次加载状态:NotLoaded、Loading、Loaded 或 Failed;Scene.Loaded 仅在状态为 Loaded 时返回 true。加载失败不会触发 OnLoadEnd。
卸载场景
csharp
#if SERVER
scene.Unload(); // 销毁场景中所有实体,释放资源
#endif场景与 GameMode 关联
GameDataGameMode.SceneList 指定游戏模式使用的场景列表:
csharp
_ = new GameDataGameMode(myModeLink)
{
Name = "My Game",
Gameplay = GSC.GameDataGameplay.Default,
PlayerSettings = GSC.GameDataPlayerSettings.Default,
SceneList = [ScopeData.GameDataScene.new_scene],
DefaultScene = ScopeData.GameDataScene.new_scene,
AutoLoadDefaultSceneInClientOnly = false,
GameUI = GSC.GameDataGameUI.Default,
};ScenePoint
ScenePoint 是场景中的水平坐标(X, Y)加可选高度(Z),并关联特定场景实例。new ScenePoint(x, y, scene) 使用场景表面默认高度;需要显式高度时使用 Vector3 构造。
csharp
var point = new ScenePoint(100, 200, scene); // X, Y
var point3d = new ScenePoint(new Vector3(100, 200, 50), scene); // X, Y, Z(高度)常用操作
csharp
float dist = pointA.Distance(pointB); // 3D 距离
float? dist2d = pointA.Distance2D(pointB); // 2D 距离(忽略高度)
ScenePoint mid = pointA.Lerp(pointB, 0.5f); // 中点插值
ScenePoint moved = point.PolarTo(angle, 100); // 极坐标偏移
Angle? angle = pointA.AngleTo(pointB); // 朝向角度
ScenePoint safe = point.Constrained; // 钳制到场景范围内
bool blocked = point.IsBlocked(); // 是否不可行走空间查询
csharp
var nearby = scene.SearchCircle(center, 500f); // 圆形
var inRect = scene.SearchRectangle(pos, facing, 200, 400); // 矩形
var inCone = scene.SearchCone(pos, facing, 600, new Angle(60)); // 扇形
var inLine = scene.SearchLine(pos, facing, 100, 500); // 线形
var local = point.SearchInRange(300f, e => e is Unit u && u.IsAlive); // 便捷方法ScenePoint 扩展(GameSparkCore)
csharp
using gamesparkcore.SceneSystem;
var safePoint = ScenePointExtensions.ClampToMap(point);
var randomPos = ScenePointExtensions.RandomInRadius(center, 500f, random);
var ringPos = ScenePointExtensions.RandomInRing(center, 200f, 600f, random);常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
| 场景加载失败(无报错) | HostedSceneTag 不匹配任何场景资产 | 使用 ScopeData.GameDataScene 中已有的场景 |
使用 Scene.CreateNewScene() | 该 API 不存在 | 使用 Scene.GetOrCreate(link) + Load() |
纯客户端中调用 scene.Load() | Load() 是服务端 API | 在 #if CLIENT 且 Game.IsClientOnly 分支中使用 scene.LoadClientOnly() |
ScenePoint 构造后坐标异常 | 使用了不同场景的 ScenePoint | 确保 ScenePoint 的 scene 参数与操作场景一致 |
SearchCircle 返回 null | 范围内无实体,或场景未加载 | 先确认 scene.Loaded == true |
相关文档
- 3D 场景创建、SpaceModel 与场景产物 — 编辑器中的 3D 场景创建、配置和生成流程
- UnitSystem.md — 单位创建需要 ScenePoint
- 3d-unit-game Skill — 3D 单位游戏完整示例
- PlayerSystem.md — 玩家与场景的关系