传送点
场景里那些可交互的传送水晶/传送门。本模块管的是解锁状态与表现, 真正的位移交给 ① 传送模块。 核心难点是:解锁状态不能用服务器的实体 State——那是全服玩家共用的。
ECS 组件
协议 23707
FlatBuffers 配置表
GameplayTag
1. 模块定位
1.1 三个可分离的关注点
| 关注点 | 落在哪 | 说明 |
|---|---|---|
| 解锁数据 | SceneItemController | 唯一权威持有者。UnlockTeleportIdSet |
| 场景表现 | EtTeleportComponent | 实体组件,按解锁与否切 LevelTag(水晶亮/暗) |
| 地图标记 | TeleportMarkItem | 大地图 / 罗盘两套资源,图标按解锁态变化 |
一个文件里两条独立职责,改动前先分清
SceneItemController 同时承载解锁状态维护和请求传送两条链路,
它们的调用方、协议、失败路径都不一样。
改其中一条前先确认自己在哪条链路上,不要顺手动另一条。
改其中一条前先确认自己在哪条链路上,不要顺手动另一条。
2. 配置表 Teleporter
FlatBuffers 自动生成,源文件 c.传送.xlsx,表名 Teleporter,
生成到 Src/Core/Define/Config/Teleporter.ts。只有 4 个字段:
| 字段 | 类型 | 含义 | 用在哪 |
|---|---|---|---|
| Id | int32 | 传送点 Id(业务主键) | 协议、解锁集合、地图标记 |
| LevelId | int32 | 所属关卡 Id | 过滤本图传送点 |
| TeleportEntityConfigId | int32 | 场景实体的 PrefabDataId | 反查映射:实体 → 传送点 Id |
| DefaultUnlock | bool | 是否默认解锁 | 初始化时直接进解锁集合 |
为什么需要 PrefabDataId ↔ TeleportId 双向映射
场景里的实体只知道自己的
PrefabDataId,但协议和存档用的是 TeleportId。
所以 InitTeleporter() 建了一张 PrefabDataId2TeleportIdMap 做正查,
反查则遍历解锁集合用 configTeleporterById。
3. 解锁状态:三条数据来源
这是本模块最容易漏的地方——解锁数据有三个入口,改逻辑时要全部覆盖。
1
配置默认解锁 · SceneItemController.InitTeleporter()
遍历
configTeleporterAll,DefaultUnlock === true 的直接 UnlockTeleport(id)。
同时建 PrefabDataId2TeleportIdMap2
服务器地图信息 · LevelMapModel.AddMapInfo()
遍历
levelMapInfo.unlockTeleportList。这是玩家个人存档里已解锁的传送点3
增量补解锁 · LevelTeleportAssistant
玩家在游戏中新解锁传送点时,服务器推送增量
3.1 关键设计:不能用服务器 State
// 值得注意的是,传送点解锁的表现不能根据服务器 State,
// 因为 State 是全部玩家共用的,
// 所以需要通过玩家个人解锁存档,去设置本地 State
const isUnlock = ControllerHolder.SceneItemController
.CheckTeleportIsUnlocked(this.DataComp!.TeleporterId);
这是本模块的核心设计约束
场景实体的
State 字段是服务器权威、全服共享的。
如果传送点解锁用它,会出现「玩家 A 解锁了,玩家 B 走过去发现也是亮的」。
所以解锁表现必须纯客户端本地驱动:读玩家个人存档 → 本地 ChangeLevelTagByClient。
3.2 解锁的实现
/** 已解锁的传送点集合 */
private readonly UnlockTeleportIdSet: Set<number> = new Set();
/** 解锁传送点 */
public UnlockTeleport(teleportId: number): void {
if (!this.UnlockTeleportIdSet.has(teleportId)) { // 幂等:三个来源可能重复解锁同一个
this.UnlockTeleportIdSet.add(teleportId);
Log.Info(ELogModule.Level, ELogAuthor.TJH, '[传送点] 解锁传送点', ['TeleportId', teleportId]);
EventSystem.Emit(EEventName.OnTeleportUnlocked, teleportId); // 通知表现层刷新
}
}
幂等设计很重要:三条来源可能对同一个传送点重复解锁,
不做 has 判断就会重复 Emit 事件,导致表现层重复刷新。
4. 表现层 EtTeleportComponent
实体侧 ECS 组件,62 行。职责只有一件事:按解锁与否切 LevelTag。
@RegisterComponent(EComponent.EtTeleportComponent)
export class EtTeleportComponent extends EntityComponent {
public DataComp: $Undefinable<ITeleportComponent> = undefined;
protected override OnStart(): boolean {
const prefabDataId = this.EntityHandle!.PrefabDataId;
const entityData = EntityInterfaceHelper.GetCompleteEntityData(prefabDataId);
if (!entityData) { return false; }
this.DataComp = getComponent(entityData.ComponentsData, EComponentData.TeleportComponent);
if (!this.DataComp) { return false; }
EventSystem.Add(EEventName.OnTeleportUnlocked, this.HandleOnTeleportUnlocked);
this.RefreshIsUnlocked(); // 首次立刻刷一次(可能实体创建时已经是解锁态)
return true;
}
/** 刷新是否解锁 */
private RefreshIsUnlocked(): void {
const levelTagComp = this.GetComponent(EComponent.LevelTagComponent);
if (levelTagComp) {
const isUnlock = ControllerHolder.SceneItemController
.CheckTeleportIsUnlocked(this.DataComp!.TeleporterId);
if (isUnlock) {
levelTagComp.ChangeLevelTagByClient(
GameplayTagUtils.GetTagIdByName('关卡.Common.数字状态.状态2')!);
} else {
levelTagComp.ChangeLevelTagByClient(
GameplayTagUtils.GetTagIdByName('关卡.Common.数字状态.状态1')!);
}
}
}
}
| 要点 | 说明 |
|---|---|
OnStart 里既订阅又立刻刷一次 | 实体可能在解锁事件之后才创建,只订阅会漏掉初始态 |
ChangeLevelTagByClient | 带 ByClient 后缀,明确表示这是纯本地状态切换,不同步服务器 |
状态1 / 状态2 | GameplayTag 名,美术在 UE 里按 tag 绑不同的材质/特效。TS 侧不关心具体表现 |
| 三次提前 return | 拿不到 entityData / DataComp 就返回 false,组件不启用。避免后续空指针 |
5. 发起传送
5.1 唯一调用点
全工程只有一处发起传送点传送:TeleportController.ts:1853
// 期间镜头停在世界画面但角色已经隐身,会出现穿帮帧。
// 这里立刻盖瞬时遮罩兜住到 OnTeleportSelfSCNotify 入口(入口处会复用同一个遮罩,不会重复 Add)。
// 后续流送完成时由 streaming-complete 关掉,loading 场景的 seq 已经接管屏幕,不会留空。
this.OpenSeamlessTransitionMask();
ControllerHolder.SceneItemController.RequestTeleportByEntityPrefabDataId(
ModelManager.TeleportModel!.TeleportPointId,
);
this.EndTeleportUp(Global.PlayerEntity);
5.2 RequestTeleport 实现
/** 传送点,请求传送 */
public RequestTeleport(teleportId: number): void {
if (!teleportId || teleportId <= 0) { // 参数防御
Log.Warn(..., '请求传送被拦截:teleportId 非法', ['teleportId', teleportId]);
return;
}
const playerEntity = Global.PlayerEntity;
if (!playerEntity) { return; }
ModelManager.GameModeModel!.IsTeleporting = true; // 全局态:屏蔽其它交互
const teleportCsReq = new Nami.Protocol.WorldTeleportCSReq();
teleportCsReq.teleportId = teleportId;
Log.Info(ELogModule.Net, ELogAuthor.ZZB,
'传送-发包 WorldTeleportCSReq(23707)', ['teleportId', teleportId]);
Net.Call(ERequestMessageId.WorldTeleportCSReq, teleportCsReq, (response) => {
ModelManager.GameModeModel!.IsTeleporting = false; // 无论成败都复位
Log.Info(ELogModule.Net, ELogAuthor.ZZB, '传送-收包 WorldTeleportCSRsp',
['teleportId', teleportId], ['retcode', response?.retcode]);
if (response!.retcode !== Nami.Protocol.Retcode.RET_SUCC) {
ConfigManager.ErrorCodeConfig!.GetTextByErrorId(response!.retcode); // 弹错误提示
Log.Info(..., '请求传送失败', ['retcode', response!.retcode]);
}
});
}
三个可复用的写法
① 入口参数防御 + Warn 日志,而不是静默 return;
② 全局态
IsTeleporting 在回调里无条件复位,不管成功失败——
这正是 IsStreamingLevel 漏复位 bug 的反面教材;
③ 收发包成对打日志且带同一个 teleportId,线上能直接对上号。
6. 地图标记 TeleportMarkItem
export class TeleportMarkItem extends MarkItemBase {
// 按地图类型切不同资源:罗盘用小图标,大地图用完整图标
public get MarkViewResId(): string {
return this.MapType === EMapType.Compass
? 'UiItem_WarpGateCompass'
: 'UiItem_Teleport';
}
// 解锁态转发给权威持有者,自己不缓存
public get IsUnlocked(): boolean {
return ControllerHolder.SceneItemController
.CheckTeleportIsUnlockedByEntityPrefabDataId(this.MarkConfig!.EntityConfigId);
}
public GetMapMarkTypeName(): string { return 'Map_Mark_Teleport'; }
}
「不缓存、只转发」是刻意的
标记的解锁态如果自己缓存一份,就要处理「解锁事件到达时刷新缓存」,
多一条同步链路多一个 bug 点。改成 getter 实时问权威持有者,性能开销可忽略(一次 Set.has)。
7. 最近传送点检索
/** 获取距离坐标最近的已解锁的传送点 */
public FindNearestTeleporterPrefabDataId(location: Readonly<Vector>): $Undefinable<number> {
// 遍历 UnlockTeleportIdSet
// 用 configTeleporterById 查配置
// 只在 curMapPath === teleporterMapPath(同一 WP)的传送点中比较
// 按 Size2D() 找最近 —— 注意是 2D 距离,忽略高度差
}
| 细节 | 为什么 |
|---|---|
| 只遍历已解锁集合 | 没解锁的传送点玩家去不了,算了也没用 |
过滤 mapPath 相同 | 跨 WP 的传送点算欧氏距离没有意义(坐标系不同) |
用 Size2D() 不是 Size() | 玩家关心的是「水平上最近」。垂直方向的高塔顶部传送点不应该因为 Z 差大就被排除 |
典型用途:坠落重生 / 边界重生找回城点、UI 显示「最近传送点」。
8. 常见修改场景
8.1 加一个新传送点
1
UE 里摆实体
配好
TeleportComponent 组件数据里的 TeleporterId2
填 c.传送.xlsx
Id / LevelId / TeleportEntityConfigId / DefaultUnlock 四个字段
3
导表
用 ConfigTool.Gui.exe,不是 Excel 宏
4
TS 侧零改动
配置驱动,代码不用动
8.2 改解锁表现(比如加个解锁动画)
改 EtTeleportComponent.RefreshIsUnlocked()。注意它会被两处调用:
OnStart(初始态)和 HandleOnTeleportUnlocked(解锁事件)。
初始态时不应该播解锁动画——玩家进图时已解锁的传送点不该重新播一遍。
所以要加参数区分「首刷」和「事件触发」。
8.3 排查「传送点该亮没亮」
1
grep 日志
[传送点] 解锁传送点确认这个 Id 到底有没有进解锁集合
2
没有 → 查三条来源
配置
DefaultUnlock?服务器 unlockTeleportList?增量补发?3
有但没亮 → 查
TeleporterId 对不对实体组件数据里的 Id 与配置表 Id 不一致是最常见原因
4
Id 对但没亮 → 查 GameplayTag
关卡.Common.数字状态.状态2 在 UE 里有没有绑对应的材质9. 面试怎么讲
传送点的解锁状态为什么不能用服务器下发的 State设计
因为场景实体的
所以设计上把它做成纯客户端本地驱动:解锁数据来自玩家个人存档, 表现用
State 是服务器权威、全服共享的一个字段,
它表达的是「这个实体客观处于什么状态」,比如宝箱开没开。
但传送点解锁是每个玩家各自的进度——A 解锁了不代表 B 解锁了。
用 State 会导致 A 解锁后 B 走过去发现也是亮的。所以设计上把它做成纯客户端本地驱动:解锁数据来自玩家个人存档, 表现用
ChangeLevelTagByClient 切本地 GameplayTag,不回写服务器。
API 命名上刻意带 ByClient 后缀,让后来的人一眼看出这是本地态。
解锁数据有三个来源,怎么保证不出问题概览
三个来源是:配置表
① 单一权威持有者:全部收敛到
② 入口幂等:
DefaultUnlock、服务器地图信息 unlockTeleportList、运行中的增量推送。
关键做了两件事:① 单一权威持有者:全部收敛到
SceneItemController.UnlockTeleportIdSet 一个 Set,
表现层和地图标记都不缓存副本,用 getter 实时问。② 入口幂等:
UnlockTeleport 先 has 判断,
重复解锁不重复 Emit 事件。三条来源重叠是常态(比如默认解锁的点服务器也会下发),
不做幂等就会重复刷新表现。
最近传送点为什么用 2D 距离细节
因为玩家的心理预期是「水平上最近」。如果用 3D 距离,一个正下方 500 米的地下城传送点
会被判定为比水平 100 米的地面传送点更远/更近,都不符合直觉。
另外还过滤了
mapPath——跨 World Partition 的坐标系不同,算欧氏距离没有意义。