每年冬天,暴风城和奥格瑞玛会冒出圣诞树、雪人、还有卖节日物品的 NPC。这些角色平时不存在——它们不是"藏"在城里等触发,而是真的从数据库里凭空生成。节日结束,它们又消失。
这就是 game_event 系统做的事:在已有的游戏世界之上,临时叠加一层内容。不是修改原数据,而是把另一组对象"挂"上去——节日 NPC、节日任务、节日掉落、节日商人,整整齐齐地贴在时间线上。
上一篇讲了 conditions 表在代码里的运行机制。这篇讲它的"雇主"之一——GameEventMgr,看它是怎么管理"什么时候激活什么"的。
一、数据结构:一个事件长什么样
GameEventData描述一个游戏事件的完整画像:
structGameEventData{uint32 EventId;// 事件 IDtime_t Start;// 开始时间(Unix 时间戳)time_t End;// 结束时间uint32 Occurence;// 周期(分钟),两次事件间隔uint32 Length;// 持续时间(分钟)HolidayIds HolidayId;// 关联的节日 DBC IDuint8 HolidayStage;// 节日阶段GameEventState State;// 当前状态(6 种)GameEventConditionMap Conditions;// 世界事件条件std::set<uint16>PrerequisiteEvents;// 前置事件std::string Description;// 事件描述uint8 Announce;// 是否公告time_t NextStart;// 下一个事件的启动时间};关键字段解释:
- Occurence + Length:周期性事件的时间模型。比如"每 2 周开启一次,每次持续 3 天"——Occurence=20160(分钟),Length=4320(分钟)。
- State:事件的 6 种状态,后文详述。
- PrerequisiteEvents:前置事件——某些世界事件必须等前一个阶段完成后才能启动。
6 种状态
enumGameEventState{GAMEEVENT_NORMAL=0,// 标准周期事件GAMEEVENT_WORLD_INACTIVE=1,// 世界事件:未启动GAMEEVENT_WORLD_CONDITIONS=2,// 世界事件:条件收集中GAMEEVENT_WORLD_NEXTPHASE=3,// 世界事件:条件满足,等待定时器GAMEEVENT_WORLD_FINISHED=4,// 世界事件:已完成GAMEEVENT_INTERNAL=5,// 内部事件:永不自动触发};前两种是"简单模式"和"复杂模式"的分野:
- NORMAL:纯时间驱动——到了时间就开,过了时间就关。暗月马戏团、季节性节日都是这种。
- WORLD_*:条件驱动——除了时间,还要满足额外条件(比如全服玩家完成一定数量的特定任务),走四阶段状态机。
二、加载:15 张表拼出完整的事件层
LoadFromDB()按 15 步加载,顺序有依赖:
voidGameEventMgr::LoadFromDB(){LoadEvents();// 1. 事件基础信息(game_event 表)LoadEventSaveData();// 2. 事件存档状态(game_event_save)LoadEventPrerequisiteData();// 3. 前置事件LoadEventCreatureData();// 4. 事件关联的 Creature guidLoadEventGameObjectData();// 5. 事件关联的 GameObject guidLoadEventModelEquipmentChangeData();// 6. NPC 模型/装备替换LoadEventQuestData();// 7. 事件关联的任务LoadEventGameObjectQuestData();// 8. 事件关联的 GameObject 任务LoadEventQuestConditionData();// 9. 任务的进度贡献条件LoadEventConditionData();// 10. 世界事件条件定义LoadEventConditionSaveData();// 11. 世界事件条件存档LoadEventNPCFlags();// 12. 事件期间 NPC 标志位变化LoadEventSeasonalQuestRelations();// 13. 季节性任务映射LoadEventVendors();// 14. 事件期间的 NPC 商人商品LoadEventBattlegroundData();// 15. 战场节日设置LoadEventPoolData();// 16. 事件关联的生成池}每一步读一张表,把数据挂到对应的数据结构上。核心容器:
| 容器 | 存什么 | 对应表 |
|---|---|---|
GameEventCreatureGuids[eventId] | 事件激活时生成的 Creature guid 列表 | game_event_creature |
GameEventGameobjectGuids[eventId] | 事件激活时生成的 GameObject guid 列表 | game_event_gameobject |
_gameEventCreatureQuests[eventId] | 事件期间可接的任务 | game_event_creature_quest |
_gameEventModelEquip[eventId] | NPC 模型/装备替换 | game_event_model_equip |
_gameEventVendors[eventId] | 事件期间的临时商品 | game_event_npc_vendor |
_gameEventNPCFlags[eventId] | NPC flag 变化 | game_event_npcflag |
每个事件 ID 对应一组"要叠加什么内容"——这就是"叠加层"的具象化。
三、Update():事件引擎的主循环
GameEventMgr::Update()是整个事件系统的心跳,由World::Update()定期调用,返回"下次应该多久再检查":
uint32GameEventMgr::Update(){time_t currenttime=GameTime::GetGameTime().count();uint32 nextEventDelay=max_ge_check_delay;// 默认 1 天std::set<uint16>activate,deactivate;for(uint16 itr=1;itr<_gameEvent.size();++itr){if(CheckOneGameEvent(itr))// 该不该开?{if(!IsActiveEvent(itr))activate.insert(itr);// 该开但没开 → 加入激活队列}else{if(IsActiveEvent(itr))deactivate.insert(itr);// 该关但没关 → 加入停用队列}calcDelay=NextCheck(itr);if(calcDelay<nextEventDelay)nextEventDelay=calcDelay;// 取最近的下次检查时间}// 先激活再停用——避免"消了又生"的闪烁for(autoitr:activate)StartEvent(*itr);for(autoitr:deactivate)StopEvent(*itr);return(nextEventDelay+1)*IN_MILLISECONDS;}关键设计:
- 先激活再停用:代码注释说得很明白——“a now activated event can contain a spawn of a to-be-deactivated one”,先激活后停用,避免同一个 NPC 消失又立刻出现的闪烁。
- 动态心跳:返回的不是固定值,而是"最近一个事件变化的倒计时"——如果没有事件即将变化,最长等一天再查。
CheckOneGameEvent:该不该开?
boolGameEventMgr::CheckOneGameEvent(uint16 entry)const{switch(_gameEvent[entry].State){caseGAMEEVENT_NORMAL:returnStart<currenttime&¤ttime<End&&(currenttime-Start)%(Occurence*MINUTE)<Length*MINUTE;caseGAMEEVENT_WORLD_CONDITIONS:caseGAMEEVENT_WORLD_NEXTPHASE:returntrue;// 条件阶段和过渡阶段都算"该开"caseGAMEEVENT_WORLD_FINISHED:caseGAMEEVENT_INTERNAL:returnfalse;// 已完成或内部事件不自动激活caseGAMEEVENT_WORLD_INACTIVE:// 检查所有前置事件是否完成for(autoprereq:PrerequisiteEvents)if(prereq 不是 NEXTPHASE/FINISHED)returnfalse;return!PrerequisiteEvents.empty();}}NORMAL 事件就是简单的时间取模判断;WORLD 事件走状态机。
四、ApplyNewEvent / UnApplyEvent:叠加层的安装与卸载
事件激活时,ApplyNewEvent()按固定顺序做 8 件事:
voidGameEventMgr::ApplyNewEvent(uint16 eventId){// 1. 发公告(如果配置了的话)// 2. 生成事件专属的 Creature 和 GameObjectGameEventSpawn(eventId);// 3. 反生成"非事件时"的对象GameEventSpawn(-eventId);// 4. 替换 NPC 模型和装备ChangeEquipOrModel(eventId,true);// 5. 激活事件专属任务UpdateEventQuests(eventId,true);// 6. 更新世界状态 UIUpdateWorldStates(eventId,true);// 7. 更新 NPC 标志位(比如给 NPC 加任务标志)UpdateEventNPCFlags(eventId);// 8. 添加节日商人商品UpdateEventNPCVendor(eventId,true);// 9. 更新战场节日设置UpdateBattlegroundSettings();// 10. 触发 SmartAI 的 GAME_EVENT_STARTRunSmartAIScripts(eventId,true);// 11. 重置季节性任务(如果是首次激活)}停用时UnApplyEvent()做完全对称的反向操作:反生成、还原模型、移除任务、移除商品……每个操作都是 Apply 的镜像。
正负事件 ID 的妙用
GameEventSpawn(eventId)生成事件专属对象,GameEventSpawn(-eventId)生成"非事件时"的对象。数据库里用正负号区分:事件 ID 为正的记录代表"事件期间才出现",为负代表"事件期间不出现"。这就像 CSS 的display:none和display:block互换——激活时显示节日版,停用时显示日常版。
GameEventSpawn 的内部逻辑
voidGameEventMgr::GameEventSpawn(int16 eventId){// 1. 把事件关联的 Creature 加入地图格子for(autoguid:GameEventCreatureGuids[internal_event_id]){sObjectMgr->AddCreatureToGrid(guid,data);// 如果格子已加载,立即生成if(map->IsGridLoaded(data->posX,data->posY)){Creature*creature=newCreature;creature->LoadCreatureFromDB(guid,map);}}// 2. 同理处理 GameObject// 3. 激活关联的生成池(Pool)for(autopoolId:_gameEventPoolIds[internal_event_id])sPoolMgr->SpawnPool(poolId);}不是"创建"新的 Creature,而是从数据库中LoadCreatureFromDB——这些生物的数据平时就存在creature表里,只是标记了属于哪个事件。GameEventMgr 的职责是在正确的时机把它们"显形"或"隐身"。
五、世界事件:四阶段状态机
周期事件只要看时间就够了。但某些事件更复杂——比如"暗月马戏团"这种需要全服玩家贡献资源的世界事件,它要回答的不是"到点了没",而是"条件够了没"。
状态机流转
WORLD_INACTIVE → WORLD_CONDITIONS → WORLD_NEXTPHASE → WORLD_FINISHED ↓ ↓ ↓ 等前置完成 收集条件进度 定时器到→启动后续事件- INACTIVE:检查前置事件是否都完成了(
PrerequisiteEvents集合)。只有所有前置事件处于 NEXTPHASE 或 FINISHED 状态,本事件才能进入下一阶段。 - CONDITIONS:等待条件满足。
GameEventConditionMap记录了多个条件,每个条件有ReqNum(需要多少)和Done(已完成多少)。 - NEXTPHASE:所有条件满足,设一个定时器(
Length分钟后),到时间后自动进入 FINISHED 并启动后续事件。 - FINISHED:事件结束,不自动重新激活。
条件进度的驱动方式
世界事件的条件不是靠定时器检查的,而是靠玩家行为推送——当玩家完成一个关联任务时:
voidGameEventMgr::HandleQuestComplete(uint32 quest_id){// 查找这个任务是否关联了某个世界事件的条件autoitr=_questToEventConditions.find(quest_id);if(itr!=_questToEventConditions.end()){uint16 eventId=itr->second.EventId;uint32 condition=itr->second.Condition;floatnum=itr->second.Num;// 这个任务贡献多少进度// 事件必须处于 CONDITIONS 阶段才计进度if(!IsActiveEvent(eventId))return;if(_gameEvent[eventId].State!=GAMEEVENT_WORLD_CONDITIONS)return;// 累加进度(不超过上限)citr->second.Done=min(citr->second.Done+num,citr->second.ReqNum);// 检查是否所有条件都满足了if(CheckOneGameEventConditions(eventId)){// 切换到 NEXTPHASE,保存状态到 DB_gameEvent[eventId].State=GAMEEVENT_WORLD_NEXTPHASE;SaveWorldEventStateToDB(eventId);sWorld->ForceGameEventUpdate();// 立刻触发下次 Update}}}这段代码的妙处:推拉结合。进度靠推送(玩家交任务时立即累加),阶段切换靠拉取(Update 主循环检测到条件满足后切换状态)。推送保证了实时性,拉取保证了状态一致性。
六、conditions 表与 GameEventMgr 的关系
上一篇的 conditions 系统和本篇的 GameEventMgr 是两个独立的系统,但有关联点:
- CONDITION_ACTIVE_EVENT:conditions 表里有一个条件类型专门判断"某个 GameEvent 是否激活"——
ConditionType=12, Value1=event_id。这意味着任何条件判断(任务可见性、战利品掉落、法术施放)都可以引用游戏事件状态。 - SmartAI 的 GAME_EVENT_START/END:事件激活/停用时,
RunSmartAIScripts()会触发 SmartAI 的 Hook,而 SmartAI 的事件本身又可以用 conditions 表做条件过滤。 - 世界事件条件 vs conditions 表:这是两个不同的"条件"系统——世界事件条件(
GameEventConditionMap)是全服进度(比如"全服玩家交了 5000 个任务"),conditions 表是个人条件(比如"这个玩家有没有完成前置任务")。前者驱动事件阶段流转,后者驱动个人可见性。
七、叠加层的本质
回看整个 GameEventMgr,它的核心抽象就是四个字:叠加层。
游戏世界的基础内容(常驻 NPC、常驻任务、常驻掉落)是"底层"。游戏事件是一层可以临时贴上去、随时撕下来的"贴纸"——节日 NPC、节日任务、节日商品、NPC 模型替换、战场节日设置,全是贴纸上的内容。
叠加层的设计优势:
- 零侵入:不需要修改常驻数据。暗月马戏团的 NPC 不是"修改"了某个现有 NPC,而是"新增"了一组独立存在的对象。
- 可逆:停用事件时,只需反向操作——反生成、还原模型、移除商品。底层世界完好如初。
- 可组合:多个事件可以同时激活,各自叠加自己的内容层。
叠加层的代价:
- 数据膨胀:每增加一个事件,要配套十几张关联表的数据行。一个完整的节日活动可能涉及上百行跨表记录。
- 调试复杂:某个 NPC 为什么出现了/消失了?答案可能藏在
game_event_creature表的正负号里,而不是 creature 表本身。 - 内存占用:所有事件数据在启动时全量加载。不过事件数量有限(通常几十个),内存开销可控。
叠加层不是 AzerothCore 独创——它是 MMO 服务器处理"临时内容"的标准范式。暴雪官方服务端用同样的思路,只是数据表名字不同。本质上是用空间换安全:宁可多存一组"事件专属"的数据,也不冒险修改常驻数据。
事件系统像一个舞台总监——它不演戏,但它决定什么时候拉幕、什么时候换景、什么时候上群演。它和数据层配合,让一个静态的世界按时间表"活"起来。