团本首帧快照、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 二进制包:
- 写入
Heros.Count。 - 第一个序列化新成员自己的 Hero。
- 再序列化其他 Hero。
- 每个实体写入
EntityType、entityId、ConfigId、playerId。 - 最后调用实体自己的
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,带:
EntityIdTimestamp- 优化后的
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 时加入:
SC_EnterBattle告诉 B 玩家列表和 Battle 状态。SC_SceneInfo告诉 B 地图与剩余时间。AddEntity序列化当前 Boss,包含当前属性,而不是出生满血。- 后续每次属性变化继续走
SyncProperty。 - 移动、动作、Buff、技能分别走自己的增量包。
如果只发增量,B 不知道加入之前发生了什么;如果一直发全量,带宽和反序列化成本太高。这里使用的是游戏状态同步常见的“加入点全量,加入后增量”。
10. 首帧排错顺序
客户端进图但看不到 Boss 时,按下面顺序检查:
- 是否收到
SC_EnterBattle,BattleId 是否正确。 - 是否收到
SC_SceneInfo,地图SceneInfo.Id与难度是否正确。 - 服务端
BattleGuildPveBoss.CurRound.BossId是否有效。 - Boss 是否完成
CellMgr.EventRegister。 LiveStatus是否仍为ACTIVE。IsSyncToHero是否把 Boss 过滤掉。AddEntity分批计数与客户端二进制读取是否一致。- 客户端
ClientCreateEntitySystem是否能按 ConfigId 创建 Boss。 - 配置和资源版本是否与服务端一致。
- 首帧后是否持续收到
SyncProperty/StateSync/MoveBegin。