毒圈

评评① 0 到 1 的大世界玩法模块:安全区收缩圈(吃鸡式缩圈)。 设计核心是服务器只发「关键帧」,客户端逐帧插值出整个收缩过程—— 服务器不用每帧广播,客户端断线重连后也能凭一对时间戳立刻对齐当前圈。 三态(静态圈/动态圈/无圈)由服务器协议字段直接驱动。

0 到 1 服务器关键帧 + 客户端插值 三态协议驱动 时间戳三重防御 MPC 地形材质联动

1. 工作清单

内容性质
毒圈 0 到 1:协议对接、三态圈(静态/动态/无)、动态收缩插值、Niagara 圈体渲染、MPC 写地形材质参数、Action 注册(Create/Shrink/Destroy) 0 建

2. 理论:服务器权威关键帧 vs 客户端逐帧插值

2.1 数据流:一对时间戳驱动整个收缩

1
服务器下发关键帧
WorldPoisonZoneUpdateSCNotify 一次通知:起点(位置/半径)+ 终点 + beginTimeMs/endTimeMs
2
客户端缓存时间戳
Model.SetInfo 缓存 begin/end 时间戳,之后不再依赖服务器
3
每帧本地插值
View.Update 每帧算 progress = (now - begin) / (end - begin),插值位置和半径
4
写渲染侧
SetActorLocation + SetActorScale3D 驱动 Niagara 圈体;中心/半径写 MPC 供地形材质做圈外毒雾
为什么关键帧方案对多人游戏是「免费的一致性」 服务器不用按帧广播收缩状态(带宽随人数×帧率爆炸); 所有玩家的时钟推进各评插值,圈天然同步; 断线重连/顶号后只要收到一次通知,立刻对齐到当前圈的精确状态—— 这是「用时间换带宽」的典型服务器权威设计。

2.2 三态状态机:协议字段即状态

状态判定客户端行为
EPoisonZoneType.NoneInfo 为空销毁圈体、清全屏特效、重置 MPC
EPoisonZoneType.StaticstaticPoisonZone 字段存在创建固定圈,不进每帧 Update
EPoisonZoneType.DynamicdynamicPoisonZone 字段存在创建圈 + 注册每帧插值,收缩完成后定格
状态不单独存, 数据形状推导 没有独立的状态字段——哪个子结构有值就是哪个状态。 好处是协议和状态机不会失配(不存在「状态说动态但数据是静态」的 bug 空间), 坏处是 default 分支必须显式报错兜住未知类型。

2.3 时间戳防御:为什么三重检查都返回 1

异常不拦截的后果返回值
NaN(时间戳解析失败)(now-NaN)/duration = NaN,圈位置全 NaN,Niagara 表现炸1(定格终点态)
begin === end(起止相同)duration = 0,除零得 Infinity,进度永远钳不住1
begin > end(时间倒序)progress 为负,Math.max 钳到 0,圈永远卡在起点1
统一返回 1 的深意 三种异常都收敛到「已到达终点态」而不是返回 0 或抛异常: 圈已收缩完毕是一个「安全的静止画面」,玩家看到的是最终圈而不是乱跳的 NaN 圈; 同时 Error 日志带上下文字段,线上排查一眼定位是哪条协议的时间戳脏了。 防御式编程的目标不是掩盖错误,是让错误以最安全的样子显式暴露。

3. 代码片段

3.1 时间戳三重防御

Src\Game\Module\PoisonZone\PoisonZoneModel.ts:140-185

public GetProgress(): number {
    const dynamicData = this.GetDynamicPoisonZone();
    if (!dynamicData || this.CachedBeginTimeMs === undefined || this.CachedEndTimeMs === undefined) {
        return 0;
    }

    const currentTimeMs = Date.now();
    const beginTimeMs = this.CachedBeginTimeMs;
    const endTimeMs = this.CachedEndTimeMs;

    if (Number.isNaN(beginTimeMs) || Number.isNaN(endTimeMs)) {
        Log.Error(ELogModule.Level, ELogAuthor.ZZB, '毒圈-时间戳无效: ',
            ['beginTimeMs', beginTimeMs], ['endTimeMs', endTimeMs]);
        return 1;
    }

    if (beginTimeMs === endTimeMs) {
        Log.Error(ELogModule.Level, ELogAuthor.ZZB, '毒圈-开始时间和结束时间相同,异常行为: ', ...);
        return 1;
    }

    if (beginTimeMs > endTimeMs) {
        Log.Error(ELogModule.Level, ELogAuthor.ZZB, '毒圈-开始时间大于结束时间,异常行为: ', ...);
        return 1;
    }

    const totalDuration = endTimeMs - beginTimeMs;
    return Math.min(Math.max((currentTimeMs - beginTimeMs) / totalDuration, 0), 1);
}
收尾一行是标准进度钳制 计算完先 Math.max(x, 0) 防负数(服务器通知还没到生效时间)、 再 Math.min(x, 1) 防超 1(收缩已结束)。 三重防御在钳制之前——先保证分母和分子是「可钳制的数」,NaN 是钳不住的。

3.2 三段式状态计算:终点态 / 起点态 / 线性插值

Src\Game\Module\PoisonZone\PoisonZoneModel.ts:203-235

private CalculateDynamicState(): ICurrentPoisonZoneState {
    const dynamicData = this.GetDynamicPoisonZone();
    if (!dynamicData || this.CachedBeginTimeMs === undefined || this.CachedEndTimeMs === undefined) {
        return this.GetInvalidState();
    }

    const currentTimeMs = Date.now();

    if (currentTimeMs >= endTimeMs) {                       // 段1:已过结束时间 → 终点态
        return { Position: dynamicData.endPos, Radius: dynamicData.endRadius, IsValid: true, ... };
    }

    if (currentTimeMs <= beginTimeMs) {                       // 段2:未到开始时间 → 起点态
        return { Position: dynamicData.beginPos, Radius: dynamicData.beginRadius, IsValid: true, ... };
    }

    const progress = this.GetProgress();                     // 段3:进行中 → 插值
    return this.InterpolateState(dynamicData, progress);
}
边界段先于插值段的顺序讲究 先判两个「边界段」(时间未到/已过),中间才走插值。 边界判断用的是 >=/<= 闭区间—— 即便 progress 因时间戳异常返回 1,终点态分支的返回结构和插值终点完全一致,防御路径和正常路径收敛到同一个状态。

3.3 每帧插值 + 写 MPC

Src\Game\Module\PoisonZone\PoisonZoneView.ts:30-69

public Update(): void {
    if (!this.PoisonZoneInstance) { return; }

    const poisonZoneType = ControllerHolder.PoisonZoneController.GetPoisonZoneType();
    if (poisonZoneType !== EPoisonZoneType.Dynamic) { return; }   // 静态圈不进每帧

    const progress = ControllerHolder.PoisonZoneController.GetProgress();
    if (Number.isNaN(progress)) {
        Log.Error(ELogModule.Level, ELogAuthor.ZZB, '毒圈-更新进度: 进度值无效');
        return;
    }

    if (progress >= 1) {
        if (!this.IsDynamicPoisonZoneCompleted) {
            this.IsDynamicPoisonZoneCompleted = true;           // 完成时打一次日志
        } else {
            return;                                        // 已定格,停止重算
        }
    }

    const state = ControllerHolder.PoisonZoneController.GetCurrentState();
    const position = state.Position;
    if (position) {
        const newLocation = new UE.Vector(position.x, position.y, position.z);
        this.PoisonZoneInstance.K2_SetActorLocation(newLocation, false, undefined, true);
    }

    const beginRadius = state.BeginRadius ?? state.Radius;
    const endRadius = state.EndRadius ?? state.Radius;
    const currentRadius = beginRadius + (endRadius - beginRadius) * progress;

    this.SetActorScale(this.PoisonZoneInstance, currentRadius);
    this.UpdatePoisonZoneMaterialParam(position, currentRadius);
}
三个细节 ① IsDynamicPoisonZoneCompleted 完成后 early return——收缩结束的圈不再吃每帧开销; ② 半径用手写线性插值而不是 Lerp 函数,一行可读; ③ 最后一行把中心/半径写进全局 MPC(R/G/B = 位置,A = 半径), 地形材质在着色器里判定「圈外染毒」——判定成本在 GPU,不在逻辑层。

3.4 三态分发 + default 显式报错

Src\Game\Module\PoisonZone\PoisonZoneController.ts:72-108

private readonly NetWorldPoisonZoneUpdateNotify = (notify: Game.Protocol.WorldPoisonZoneUpdateSCNotify): void => {
    if (!notify || !notify.poisonZoneInfo) {
        Log.Error(ELogModule.Level, ELogAuthor.ZZB, '毒圈-服务器同步数据无效');
        return;
    }
    this.Model?.SetInfo(notify.poisonZoneInfo);
    this.OnPoisonZoneStateChanged();
};

private OnPoisonZoneStateChanged(): void {
    switch (this.GetPoisonZoneType()) {
        case EPoisonZoneType.Static:
            this.View.Create(this.GetCurrentState().Position, this.GetCurrentState().Radius, true);
            break;
        case EPoisonZoneType.Dynamic:
            this.View.Create(this.GetCurrentState().Position, fn">GetCurrentState().Radius, false);   // false=不销毁重建
            break;
        case EPoisonZoneType.None:
            this.View.Destroy();
            this.RemoveScreenEffect();
            break;
        default:
            Log.Error(ELogModule.Level, ELogAuthor.ZZB, '毒圈-未知毒圈类型');
            break;
    }
}
default 不是摆设 枚举将来加第四种圈型时,老客户端收包会走 default—— 显式 Error 而不是静默吞掉,排「新配置老客户端不显示圈」这类版本兼容问题时有日志可查。 switch 全分支 + default 报错是这工程处理枚举分发的标准姿势。

3.5 Action 注册链:三个圈操作全是服务器实现

Src\UniverseEditor\Common\Operation\Action.ts:445-458

//#region ---------------- 毒圈相关 ----------------
[EAction.CreatePoisonZone]: {
    Context: ['levelPlay'],
    ImplementType: 'Server',
},
[EAction.ShrinkPoisonZone]: {
    Context: ['levelPlay'],
    ImplementType: 'Server',
},
[EAction.DestroyPoisonZone]: {
    Context: ['levelPlay'],
    ImplementType: 'Server',
},
//#endregion ---------------- 毒圈相关 ----------------
策划在行为树里配圈,实现全在服务器 三个 Action(建圈/缩圈/毁圈)注册为 ImplementType:'Server', 策划在关卡行为树里编排「第 X 秒开始缩圈、缩到 Y 半径」, 裁定在服务器——所有玩家在同一时刻收到同一份关键帧,圈的推进天然多人一致。 客户端层(本页其余代码)只负责表现。

4. 面试怎么讲

毒圈的收缩同步方案是怎么设计的设计
服务器权威 + 关键帧插值:服务器只发一次通知, 包含起点(位置/半径)、终点、beginTimeMs/endTimeMs 一对时间戳; 客户端缓存后每帧本地插值出当前位置和半径。
收益三个:① 服务器不用按帧广播,带宽与人数解耦; ② 所有客户端时钟推进,表现天然一致; ③ 断线重连后收到一次通知就立刻对齐到当前圈的精确状态, 不需要历史轨迹回放。
客户端对脏数据做了什么防御难点
GetProgress 里对时间戳做三重防御:NaN、起止相等、时间倒序。 任何一种不拦截都会产生毒进度——NaN 让圈位置全 NaN(渲染炸), 相等除零得 Infinity(进度钳不住),倒序让圈永远卡在起点。
三种异常统一返回 1 收敛到终点态并打 Error 日志带字段上下文: 玩家看到的是安全的静止最终圈,排查时有日志一眼定位是哪条协议脏了。 防御式编程的目标不是掩盖错误,是让错误以最安全的样子显式暴露。
客户端和服务端的职责怎么切分关联
服务端:三个 Action(Create/Shrink/DestroyPoisonZone)全标 ImplementType:'Server', 策划在行为树编排,缩圈裁定和伤害判定都在服务器(客户端零伤害逻辑)。
客户端:收通知 → 三态 switch 分发(静态建圈/动态建圈进每帧/无圈销毁)→ Niagara 圈体 + MPC 写地形材质。 判定「玩家在不在圈内」这类逻辑也在服务器,客户端只做表现—— 地形毒雾用 MPC 让着色器判定圈内外,逻辑层零成本。