首页 / 大项目模块 / 传送 / 传送点

传送点

场景里那些可交互的传送水晶/传送门。本模块管的是解锁状态与表现, 真正的位移交给 ① 传送模块。 核心难点是:解锁状态不能用服务器的实体 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 个字段:

字段类型含义用在哪
Idint32传送点 Id(业务主键)协议、解锁集合、地图标记
LevelIdint32所属关卡 Id过滤本图传送点
TeleportEntityConfigIdint32场景实体的 PrefabDataId反查映射:实体 → 传送点 Id
DefaultUnlockbool是否默认解锁初始化时直接进解锁集合
为什么需要 PrefabDataId ↔ TeleportId 双向映射 场景里的实体只知道自己的 PrefabDataId,但协议和存档用的是 TeleportId。 所以 InitTeleporter() 建了一张 PrefabDataId2TeleportIdMap 做正查, 反查则遍历解锁集合用 configTeleporterById。

3. 解锁状态:三条数据来源

这是本模块最容易漏的地方——解锁数据有三个入口,改逻辑时要全部覆盖。

1
配置默认解锁 · SceneItemController.InitTeleporter()
遍历 configTeleporterAll,DefaultUnlock === true 的直接 UnlockTeleport(id)。 同时建 PrefabDataId2TeleportIdMap
2
服务器地图信息 · 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 / 状态2GameplayTag 名,美术在 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 组件数据里的 TeleporterId
2
填 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 的坐标系不同,算欧氏距离没有意义。