CD Code Digest Hub /CS:GO

实体系统:工厂、Think、I/O、游戏系统与事件

15 分钟阅读 9 个章节 1 张图

Source 的玩法几乎全部以“实体(entity)”为单位组织:玩家、武器、炸弹、触发器、出生点、游戏规则代理……本页讲解实体如何被创建、如何按时间被驱动、如何通过地图里配置的输入/输出互相通信,以及实体之外的两条横切机制——IGameSystem 与游戏事件。

相关页面:游戏 DLL 契约 · 实体复制 · 地图加载 · 实体句柄、实体列表与空间分区

相关源码文件#

  • 工厂与创建:game/server/util.h、game/server/util.cpp、game/server/mapentities.cpp、game/shared/predictable_entity.h
  • 数据描述:public/datamap.h
  • Think 与模拟:game/server/baseentity.h、game/shared/baseentity_shared.cpp、game/server/physics_main.cpp、game/shared/physics_main_shared.cpp
  • I/O:game/server/cbase.cpp、game/server/baseentity.cpp
  • 实体列表:game/server/entitylist.cpp
  • 游戏系统:game/shared/igamesystem.h、game/shared/igamesystem.cpp
  • 事件与消息:engine/GameEventManager.cpp、game/shared/usermessages.cpp

1. 创建:类名 → 工厂 → 实体#

  • LINK_ENTITY_TO_CLASS( mapClassName, DLLClassName ) 在服务端展开为一个静态 CEntityFactory<DLLClassName>:game/server/util.h:127-128;其构造函数把自己装入全局工厂字典,Create 实例化实体并返回网络化组件:game/server/util.h:98-111。例如 CS:GO 用它把 player 映射到 CCSPlayer:game/server/cstrike15/cs_player.cpp:410。
  • 工厂字典是 CUtlDict<IEntityFactory*>,重复安装同名工厂会断言,未知类名创建时告警并返回 NULL:game/server/util.cpp:74-175。
  • CreateEntityByName 可选强制 edict 索引,通过字典创建后通知实体列表:game/server/mapentities.cpp:39-61。
  • 客户端同名宏的语义不同:它在 GetClassMap() 中登记“地图类名 → 客户端类工厂”,并把地图类名写回对应的 ClientClass,主要服务于客户端预测创建与纯客户端实体:game/shared/predictable_entity.h:141-161。

实体构造完成后如何取得 edict、进入全局实体列表并获得 EHANDLE,见 实体句柄、实体列表与空间分区。

从地图实体字符串到 Spawn#

MapEntity_ParseAllEntities 循环解析 { ... } 块;每块由 MapEntity_ParseEntity 读取 classname、经过可选过滤器后创建实体,并调用 ParseMapData 把键值写入实体:game/server/mapentities.cpp:520-565、game/server/mapentities.cpp:629-686。全部解析完毕后处理模板并统一 Spawn/Activate:game/server/mapentities.cpp:567-568。层级化生成会先按父子深度排序,保证父实体先于子实体生成:game/server/mapentities.cpp:571-592。

2. 数据描述(datadesc)#

每个实体类用 BEGIN_DATADESC 声明一张字段表,表项可以是存档字段、地图键值、输入处理函数或输出:public/datamap.h:365。输入 DEFINE_INPUTFUNC 记录外部名与成员函数指针:public/datamap.h:204;输出 DEFINE_OUTPUT 记录字段偏移与外部名:public/datamap.h:211。这张表同时服务于存档/读档、地图键值解析与实体 I/O(见第 4 节)。

3. 时间驱动:Think 与模拟#

  • SetThink(fn) 设置当前的思考函数,SetNextThink(t) 设置触发时间,支持具名上下文的多个 Think:game/server/baseentity.h:2869、game/shared/baseentity_shared.cpp:951、game/shared/baseentity_shared.cpp:1234。
  • 每个服务器 tick,Physics_RunThinkFunctions 复制一份“需要模拟或思考的实体列表”,对每个实体 Physics_SimulateEntity;不模拟时只模拟玩家(以处理排队的用户命令):game/server/physics_main.cpp:2282-2339。Physics_SimulateEntity 对“由玩家驱动模拟”的实体,只要该玩家近期仍有命令到达就跳过(交由玩家命令推进),否则收回控制权;其余实体调用 PhysicsSimulate:game/server/physics_main.cpp:2209-2272。到期的 Think 由 PhysicsRunThink 执行:game/shared/physics_main_shared.cpp:1891。
  • 循环期间禁用立即删除(UTIL_DisableRemoveImmediate),删除请求进入延迟列表,在 GameFrame 的固定点统一 Release:game/server/physics_main.cpp:2312-2334、game/server/entitylist.cpp:421-438、game/server/gameinterface.cpp:1462、game/server/gameinterface.cpp:1502。

约束:在 Think/触碰回调里删除实体不会立刻生效,同一帧内其它代码仍可能拿到该实体的指针;这是延迟删除设计的直接后果。

4. 实体 I/O:地图逻辑的“连线”#

  • CBaseEntityOutput::FireOutput 遍历该输出的动作列表,把每个动作作为事件加入全局队列;动作可以有限次触发,次数用尽后从列表移除:game/server/cbase.cpp:265-330。
  • CEventQueue::ServiceEvents 取出所有到期事件:先按名字查找目标(FindEntityByName),再处理直接指针目标,都找不到时把目标当作类名再找一次;仍找不到只打印开发者消息:game/server/cbase.cpp:911-990。它在 GameFrame 的实体 Think 之后被调用:game/server/gameinterface.cpp:1494-1498。
  • CBaseEntity::AcceptInput 沿 datadesc 继承链查找外部名匹配的输入,必要时把参数转换为声明的字段类型(转换失败告警并返回 false),然后调用处理函数:game/server/baseentity.cpp:4457-4530。

观察到的边界行为:FireOutput 中“使用默认参数”的分支把调用方传入的 fDelay 加到动作自身延迟上,而“参数覆盖”分支只使用动作自身延迟:game/server/cbase.cpp:280 与 game/server/cbase.cpp:289。这是两个分支之间的行为差异;是否有意未见注释说明。

5. IGameSystem:实体之外的全局系统#

IGameSystem 为全局单例提供与 DLL、关卡、存档、帧同步的回调;IGameSystemPerFrame 额外提供服务端的“实体 Think 前/后”与“发送客户端更新前”,或客户端的“渲染前/每帧/渲染后”回调:game/shared/igamesystem.h:29-128。

  • CAutoGameSystem 在静态构造时自动挂链表,InitAllSystems 时按加入顺序加入全局列表并逐个 Init,任何一个失败则整个 DLL 初始化失败:game/shared/igamesystem.cpp:176-245。
  • 关闭按逆序进行:game/shared/igamesystem.cpp:252-255。
  • 服务端帧回调的调用点见 GameFrame:game/server/gameinterface.cpp:1468、game/server/gameinterface.cpp:1494;FrameUpdatePostEntityThink 之前会先处理“希望被安全移除”的系统:game/shared/igamesystem.cpp:344-350。

典型例子:延迟补偿管理器就是一个 CAutoGameSystemPerFrame,在实体 Think 之后记录历史位置(见 延迟补偿);服务端 VScript 也是一个游戏系统,在关卡初始化前启动虚拟机(见 VScript)。

6. 游戏事件(Game Events)#

游戏事件是带命名字段的广播消息(例如 round_start、item_purchase),由引擎的 CGameEventManager 统一分发:

  • FireEventIntern 查找事件描述,未注册的事件直接丢弃;然后按监听者类型分发:服务端监听者不接收纯客户端事件,客户端监听者只接收客户端事件,CLIENTSTUB(代表远程客户端)不接收仅服务端或本地事件:engine/GameEventManager.cpp:495-560。
  • 发送给远程客户端时事件被序列化为 CSVCMsg_GameEvent,客户端反序列化:engine/GameEventManager.cpp:610、engine/GameEventManager.cpp:684。
  • 事件定义从资源文件加载并注册:engine/GameEventManager.cpp:833、engine/GameEventManager.cpp:951。

服务端玩法代码通过 gameeventmanager->CreateEvent/FireEvent 使用它,例如回合开始:game/shared/cstrike15/cs_gamerules.cpp:8715-8739。

7. 用户消息(User Messages)#

用户消息是发给指定接收者的 Protobuf 消息(HUD 提示、伤害指示、投票等)。服务端 SendUserMessage 直接转给引擎:game/server/gameinterface.cpp:4031-4034;CS:GO 的消息编号在 ECstrike15UserMessages 中定义:game/shared/cstrike15/cstrike15_usermessages.proto:69-107;客户端用 CUserMessages::BindMessage 按类型号与分屏槽位绑定处理器:game/shared/usermessages.cpp:112-141。例如热身结束时发送 CS_UM_WarmupHasEnded:game/shared/cstrike15/cs_gamerules.cpp:9528-9529。

约束与边界#

  • 实体句柄 EHANDLE 的序列号机制、实体列表与空间分区(ISpatialPartition)已移至 实体句柄、实体列表与空间分区。
  • 有意未展开存档/读档(CSaveRestoreData):引擎在多人游戏中拒绝存档与读档(仅开发用 ConVar save_multiplayer_override 可绕过),与 CS:GO 多人主路径关系很小:engine/host_saverestore.cpp:117-118、engine/host_saverestore.cpp:488-492。datadesc 中的存档字段仍会被编译,但本 Wiki 不追踪其格式。
  • 有意未展开实体模板(point_template):它是通用地图机制,只在解析完实体后统一处理(game/server/mapentities.cpp:567-568);game/server/cstrike15 与 game/shared/cstrike15 中检索不到 point_template,仓库也不含地图文件,无法确认官方地图是否依赖它。
  • 客户端实体(C_BaseEntity)的插值与 OnDataChanged 事件只在 游戏 DLL 契约 与 实体复制 中概述。