团本首帧快照、AOI 与实时增量 private

团本首帧快照、AOI 与实时增量

本篇从客户端已经拿到 BattleId 开始,解释它连上战斗服之后,怎样得到地图、自己、队友、Boss 和进行中的战斗状态。

返回栏目首页

1. 团本没有另造一套同步协议

BattleGuildPveBoss : BattleStateSync。团本只覆盖 Boss 轮次、死亡/复活、胜负和 Redis 摘要;底层同步全部继承通用状态同步:

数据主要协议/机制
Battle 会话Packet_Battling_SC_EnterBattle
地图与剩余时间SceneInfo、SyncBattleTime
玩家列表EnterBattle.players、PlayerEnter
当前实体全量二进制 AddEntity
实体消失RemoveEntity
属性变化二进制 SyncProperty
动作状态StateSync
移动意图MoveBegin / MoveFail
伤害表现DamageResult
Buff/技能Add、Remove、Update 系列协议
复活ShowRevive、OnReviving
结算展示ShowSettlement

所以阅读团本同步时,要把“团本玩法层”和“BattleStateSync 通用层”叠起来看。

2. Battle 连接握手

客户端连接 Battle 节点时,服务端 HandleMessage.OnClientConnect 先确认:

  • playerId 能在这个 Actor 的 ComPlayerMgr 中找到。
  • Battle 尚未超过 Battle 状态。
  • 玩家尚未 settled。

失败直接发送 KickOff 并关闭连接。成功则返回:

message Packet_Battling_SC_EnterBattle {
    uint32 SessionId = 1;
    string BattleId = 2;
    emBattleType battleType = 3;
    int32 maxPlayer = 4;
    repeated t_Battling_PlayerInfo players = 5;
    bool IsReconnect = 6;
    emBattleStatus BattleStatus = 7;
    emBattleCreatorType creatorType = 8;
}

这个包回答“我连的是哪场战斗、当前有哪些玩家、是否重连”。它还不是地图和实体快照。

3. SceneInfo 是客户端建场的起点

BattleStateSyncEvent.OnPlayerEnter 无论首次进入还是重连,首先发送:

var sceneInfo = new Packet_Battling_SC_SceneInfo
{
    StartTime = Scene.StartTime,
    RunTime = Time.UnixMilliTime,
    BattleTime = Scene.BattleTime,
    SceneInfo = Scene.SceneInfo,
    LeftTime = (int)Scene.BattleTimeSpan.TotalMilliseconds,
};
session.SendPacket(
    (ushort)emPacket_Battling_SC.SceneInfo,
    sceneInfo);

客户端至少有三类消费者:

  • CS_LoadServerLevelDataSystem:根据服务端地图信息加载关卡数据。
  • CS_ReceiveBattleGameDataSystem:调用 GuildBoss.BattleGameInit(leftTime / 1000f) 初始化团本倒计时。
  • TeamSystem:建立战斗内队伍信息。

GuildBoss.BattleGameInit 在 Unity 每个 LateUpdate 用 Time.unscaledDeltaTime 本地递减。这个倒计时用于 UI 和结束前表现;服务端仍会周期发送 SyncBattleTime,真正的超时判定也在 Battle。

4. 首次进入怎样创建自己的 Hero

首次连接分支是:

SC_EnterBattle
  -> OnPlayerEnter(FirstEnter)
     -> SC_SceneInfo
     -> SC_PlayerEnter 广播给其他成员
     -> EntityFactory.Create<Hero>
     -> 绑定 Player 与 Hero
     -> 放到 Scene.BirthPosition
     -> 进入 CellMgr
     -> 激活附近怪物

核心代码做了这些事情:

var entity = Battle.EntityFactory
    .CreateWithParent<Hero>(Scene);

entity.transform.Rotation = Scene.BirthRotation;
entity.transform.Position = Scene.BirthPosition;
entity.ConfigId = player.GetHeroConfigId();

player.BindHero(entity);
Scene.AddChild(entity);
entity.position = Scene.BirthPosition;

新成员不是在客户端自己选出生点。战斗服使用地图导出的 Scene.BirthPosition/BirthRotation 创建权威 Hero,客户端再根据 AddEntity 构造本地对象。

PlayerEnter 只通知已有成员“玩家信息进入”;真正的 Hero 实体仍由 AddEntity 同步。

5. 中途加入怎样获得当前完整战场

CellMgr.EventViewEntity(hero, reconnect) 是首帧实体快照的核心。

5.1 先发所有 Hero

服务端构造一个 AddEntity 二进制包:

  1. 写入 Heros.Count。
  2. 第一个序列化新成员自己的 Hero。
  3. 再序列化其他 Hero。
  4. 每个实体写入 EntityType、entityId、ConfigId、playerId。
  5. 最后调用实体自己的 Serialize(writer) 写当前状态。

“自己先发”很重要:客户端可以先确定本地控制对象,再创建远端队友。

5.2 再分批发其他实体

服务端遍历 m_ListEntity,按 BattleEntiyMaxCount 分批发送非 Hero:

if (obj.LiveStatus == ActorLiveStatus.ACTIVE
    && obj.IsSyncToHero(hero))
{
    writer.Write((byte)obj.EntityType);
    writer.Write(obj.entityId);
    writer.Write(obj.ConfigId);
    writer.Write(obj.playerId);
    obj.Serialize(writer);
}

因此中途加入者收到的不是“Boss 出生事件重播”,而是 Boss 当前对象的序列化快照。只同步:

  • 仍处于 ACTIVE 的实体。
  • IsSyncToHero 判断应该让该 Hero 看到的实体。
  • 当前 CellMgr 已登记的实体。

子弹若在快照中被补发,会调用 ResetRemoveDelayTime(),避免客户端刚创建就因原删除时刻到达而消失。

5.3 已有成员怎样看到新成员

新 Hero 注册到 CellMgr 时:

  • AddEntity 广播给其他成员,但排除新成员自己。
  • 新成员自己走 EventViewEntity 得到“自己 + 所有人 + 其他实体”。

这避免新成员重复创建自己,同时让老成员只增量创建这一个新 Hero。

6. Boss 是怎样出现在快照里的

团本创建时按 CurRound.Difficulty 加载场景。实体配置回调中:

if (TMonster.GetConfig(entity.ConfigId)?.Type
    == (int)MonsterTypeEnum.BOSS)
{
    entity.ConfigId = CurRound.BossId;
}

所以地图负责“这里刷一个 Boss”,当前轮次负责“具体换成哪个 Boss”。随后这个 Monster 进入 CellMgr,通过 AddEntity 发给所有可见玩家。

当前 OnEntityInitDataByConfigIdCb 在尝试转换为 Monster 之前就把 triggerCountDown = true。换句话说,代码实际语义是“任意走到这个实体初始化回调的实体都会启动倒计时”,不只是注释写的“刷怪开始倒计时”。

7. 全量快照之后怎样持续同步

首帧完成后不再周期发送整个场景,而是按事件发增量。

7.1 实体生命周期

实体生成 -> CellMgr.EventRegister -> SC_AddEntity
实体销毁 -> CellMgr.EventUnRegister -> SC_RemoveEntity

Boss、普通怪、子弹、掉落物和新玩家都复用这条链。

7.2 属性同步

Entity.property.HaveChange 为真时,SyncProperty 只序列化变化的属性:

writer.Write(entity.entityId);
entity.property.SerializeChange(writer);

Boss 的战斗内血量就是沿这条链进入客户端。它不是依赖 Redis 的 curRoundInfo 刷新。

7.3 状态同步

Entity.SetState 更新服务端状态后构造 StateSync,带:

  • EntityId
  • Timestamp
  • 优化后的 ParamEx
  • 需要排除的本地操作者 PlayerId

如果状态编码校验通过,服务端通常排除发起玩家,广播给其他成员。本地玩家已经先做了表现,失败或权威变化再通过专门路径纠正。

7.4 移动同步

移动仍采用项目通用的“客户端先表现、Battle 校验并转发”模型。服务端检查实体归属、关卡进度、生命和动作状态,然后把 SC_MoveBegin 广播给其他成员。详细字段、预测、PacketCache 与纠偏见 联机同步:移动包、预测、广播与纠偏。

7.5 战斗时间

BattleStateSync.OnUpdate:

  • 每 Tick 更新 Scene。
  • IsCountDown() 为真才扣减 BattleTimeSpan。
  • 时间归零调用 OnBattleTimeOut()。
  • 按 Const.Instance.Battle.BattleSyncTime 周期广播 SyncBattleTime(leftTime)。

团本不应用普通玩法的“多人增加战斗时长”逻辑,因为 SetBattleTimeByExtraRatio 显式排除了 GameGuildPveBoss。

7.6 团本怪物 AI 与 Host 控制权

团本中的 Boss/Monster 仍通过 AddEntity、SyncProperty、StateSync、MoveBegin 等通用协议同步,但同步对象存在于 Battle 不等于 AI 在 Battle 服务器执行。

实际链路:

Battle 创建 Monster 实体
  -> Scene.HostPlayerId 指定一个玩家
  -> MonsterMgr.OnChangeHost 把所有 Monster.playerId 指向该玩家
  -> ChangeHostPlayerId 广播实体归属
  -> Host 客户端 ChangeControllerSystem 接管 Monster
  -> Enemy.Update / EnemyStateMachine 做目标选择、追击、攻击、技能决策
  -> Host 客户端发送 MoveBegin / StateSync / FrameStateSync / 位置同步
  -> Battle 做归属与状态边界校验并转发
  -> 其他客户端按同步包表现

BattleStateSync.OnUpdate() 只推进场景更新、战斗时间、超时和时间广播,没有独立的 Monster AI Think 循环。服务端 Monster.Update() 主要做实体生命周期、死亡延迟、掉落和 WaveTrap 保底。

// Enemy.cs
void Update()
{
    _stateMachine?.UpdateState(Time.deltaTime);
}

void FixedUpdate()
{
    MoveController?.Update();
}

EnemyStateMachine 的 SearchAndLockTarget 会调用最近英雄搜索、视线检测、角度/最小距离判断;多个状态和技能分支用 if (!_enemyInstance.IsLocal) return 阻止非 Host 客户端重复决策。

服务端接收 Host 数据时的边界:

包服务端当前处理
MoveBegin校验关卡进度,按 findSelfEntity 校验实体归属,再交给 moveStatusHandler.OnMoveBegin
StateSync校验实体归属,调用 entity.SetState
FrameStateSync对批量状态逐项做实体归属检查后调用 SetState
FrameSyncEntityPostion只接受 entity.playerId == player.Playerid 的实体位置
Host 切换GiveUpHost、BegVoteHost、VoteHost 选出新玩家,再广播新的实体归属

因此团本 Boss 的最终共享属性和结算状态由 Battle Actor 保存和广播,但 AI 的目标、路径和技能决策来源是 Host 客户端。Host 断线时不是 Battle 接管 AI,而是把执行者换成另一个在线 Unity 客户端。

8. 两条 Boss HP 同步链不要混淆

场内实时链

伤害结算
 -> Monster.property.CurHP 改变
 -> SyncProperty 广播
 -> 场内客户端更新 Boss 血条

这是实时战斗状态链,Battle Actor 保存 Monster 的属性和结算结果;它与 Monster AI 决策不是同一件事。AI 目标、移动和技能意图由当前 Host 客户端产生,Battle 接收后按实体归属和状态边界处理,再把结果同步给其他客户端。

跨服务摘要链

Monster 的 HP 变化还会调用 Battle.OnBossHpChange。团本覆盖该回调:

  • 非 Boss 忽略。
  • 距上次写入不足 5 秒且 Boss 未死则忽略。
  • Boss HP 为 0 时不受 5 秒限制。
  • 异步读取公会 roundInfo。
  • 按 CurRound.Id 找到本轮。
  • 更新 HP 和人数。
  • 批量写回 roundInfo 与 curRoundInfo。
if (now - preTimestamp < 5000 && bossCurHp != 0)
    return;

这个 Redis 写入不会直接调用 NotifyBossRoundInfo 广播给大厅客户端。因此团本面板看到的 HP 是否立即更新,还取决于客户端是否重新请求公会信息;不要把它当成场内实时血条通道。

9. 为什么选择“快照 + 增量”

假设成员 B 在 Boss 剩余 37% HP 时加入:

  1. SC_EnterBattle 告诉 B 玩家列表和 Battle 状态。
  2. SC_SceneInfo 告诉 B 地图与剩余时间。
  3. AddEntity 序列化当前 Boss,包含当前属性,而不是出生满血。
  4. 后续每次属性变化继续走 SyncProperty。
  5. 移动、动作、Buff、技能分别走自己的增量包。

如果只发增量,B 不知道加入之前发生了什么;如果一直发全量,带宽和反序列化成本太高。这里使用的是游戏状态同步常见的“加入点全量,加入后增量”。

10. 首帧排错顺序

客户端进图但看不到 Boss 时,按下面顺序检查:

  1. 是否收到 SC_EnterBattle,BattleId 是否正确。
  2. 是否收到 SC_SceneInfo,地图 SceneInfo.Id 与难度是否正确。
  3. 服务端 BattleGuildPveBoss.CurRound.BossId 是否有效。
  4. Boss 是否完成 CellMgr.EventRegister。
  5. LiveStatus 是否仍为 ACTIVE。
  6. IsSyncToHero 是否把 Boss 过滤掉。
  7. AddEntity 分批计数与客户端二进制读取是否一致。
  8. 客户端 ClientCreateEntitySystem 是否能按 ConfigId 创建 Boss。
  9. 配置和资源版本是否与服务端一致。
  10. 首帧后是否持续收到 SyncProperty/StateSync/MoveBegin。

上一篇:创建、BattleId 路由与中途加入 · 下一篇:断线重连、复活与结算