Skip to content

场景系统(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 的关键属性:

属性类型说明
HostedSceneTagHostedSceneTag必填。场景资产标识,必须对应已有地形文件
SizeVector2场景尺寸(世界单位),须为 256 的倍数,并与地形资产尺寸一致。地形编辑器属性面板按 1~128 格显示,保存时每格换算为 256 世界单位
DefaultCameraIGameLink<GameDataCamera>客户端加载时使用的默认相机
RenderScenerybool是否渲染地形/装饰
SpaceModelSceneSpaceModel场景空间模型,决定按 2.5D 单层地表还是真 3D 多层空间处理寻路、单位高度与碰撞;不决定渲染维度
VisionSceneVisionConfig?场景级视野配置,支持正常视野、视野全开和精确视野。详见 场景视野系统
PlacedSceneShapesQuickDictionary预放置的形状(区域、点、线)
PlacedPlayerObjectsQuickDictionary预放置的单位/物品

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();
#endif

Scene.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 不是客户端完成通知,也不能作为英雄、库存和主控全部迁移成功的证明。

显式迁移原英雄的顺序 ​

以下步骤适用于保留原英雄及其库存的服务端流程。目标场景需要先完成服务端加载;客户端加载另行验收。

  1. 保存原英雄引用及 EntityId、原玩家槽位 player.Id、原场景和位置、原主控绑定,以及待校验的库存物品身份。对同一玩家的迁移请求做互斥或去重,防止重复触发交错执行。
  2. 调用 hero.MoveTo(targetPoint) 并检查返回值。目标点属于不同场景时,该调用会先尝试迁移实体,再设置位置;失败时也要读取实际状态,不能假定此前步骤完全没有生效。当前框架会在 Unit 切场景后尝试递归迁移库存中的物品单元,项目应验证结果,无需逐件调用内部迁移接口。
  3. 核对原英雄仍有效、EntityId 与所属 Player 未变、位置及 Scene 正确;通过 target.GetUnit(hero.EntityId) 和原场景查询核对登记已转移。核对库存仍属于原英雄、物品单元已进入目标场景。场景登记由框架维护,项目不手工修改集合。
  4. 调用 player.SwitchScene(target, withMainUnit: false) 并检查返回值与 player.Scene。
  5. 确认服务端 player.Scene == hero.Scene 后,设置 player.MainUnit = hero 恢复原英雄控制权;即使托管属性看起来仍指向原英雄,也要核对切场景后的实际控制绑定。不同场景时不要提前绑定。
  6. 客户端等待目标场景加载完成,并确认本地英雄副本和 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();
}
#endif

LoadClientOnly() 会在 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();
}
#endif

OnLoadStart / 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

相关文档 ​