毒圈
评评① 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.None | Info 为空 | 销毁圈体、清全屏特效、重置 MPC |
| EPoisonZoneType.Static | staticPoisonZone 字段存在 | 创建固定圈,不进每帧 Update |
| EPoisonZoneType.Dynamic | dynamicPoisonZone 字段存在 | 创建圈 + 注册每帧插值,收缩完成后定格 |
状态不单独存, 数据形状推导
没有独立的状态字段——哪个子结构有值就是哪个状态。
好处是协议和状态机不会失配(不存在「状态说动态但数据是静态」的 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 日志带字段上下文: 玩家看到的是安全的静止最终圈,排查时有日志一眼定位是哪条协议脏了。 防御式编程的目标不是掩盖错误,是让错误以最安全的样子显式暴露。
三种异常统一返回 1 收敛到终点态并打 Error 日志带字段上下文: 玩家看到的是安全的静止最终圈,排查时有日志一眼定位是哪条协议脏了。 防御式编程的目标不是掩盖错误,是让错误以最安全的样子显式暴露。
客户端和服务端的职责怎么切分关联
服务端:三个 Action(Create/Shrink/DestroyPoisonZone)全标
ImplementType:'Server',
策划在行为树编排,缩圈裁定和伤害判定都在服务器(客户端零伤害逻辑)。
客户端:收通知 → 三态 switch 分发(静态建圈/动态建圈进每帧/无圈销毁)→ Niagara 圈体 + MPC 写地形材质。 判定「玩家在不在圈内」这类逻辑也在服务器,客户端只做表现—— 地形毒雾用 MPC 让着色器判定圈内外,逻辑层零成本。
客户端:收通知 → 三态 switch 分发(静态建圈/动态建圈进每帧/无圈销毁)→ Niagara 圈体 + MPC 写地形材质。 判定「玩家在不在圈内」这类逻辑也在服务器,客户端只做表现—— 地形毒雾用 MPC 让着色器判定圈内外,逻辑层零成本。