
每年冬天暴风城和奥格瑞玛会冒出圣诞树、雪人、还有卖节日物品的 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::setuint16PrerequisiteEvents;// 前置事件std::string Description;// 事件描述uint8 Announce;// 是否公告time_t NextStart;// 下一个事件的启动时间};关键字段解释Occurence Length周期性事件的时间模型。比如每 2 周开启一次每次持续 3 天——Occurence20160分钟Length4320分钟。State事件的 6 种状态后文详述。PrerequisiteEvents前置事件——某些世界事件必须等前一个阶段完成后才能启动。6 种状态enumGameEventState{GAMEEVENT_NORMAL0,// 标准周期事件GAMEEVENT_WORLD_INACTIVE1,// 世界事件未启动GAMEEVENT_WORLD_CONDITIONS2,// 世界事件条件收集中GAMEEVENT_WORLD_NEXTPHASE3,// 世界事件条件满足等待定时器GAMEEVENT_WORLD_FINISHED4,// 世界事件已完成GAMEEVENT_INTERNAL5,// 内部事件永不自动触发};前两种是简单模式和复杂模式的分野NORMAL纯时间驱动——到了时间就开过了时间就关。暗月马戏团、季节性节日都是这种。WORLD_*条件驱动——除了时间还要满足额外条件比如全服玩家完成一定数量的特定任务走四阶段状态机。二、加载15 张表拼出完整的事件层LoadFromDB()按 15 步加载顺序有依赖voidGameEventMgr::LoadFromDB(){LoadEvents();// 1. 事件基础信息game_event 表LoadEventSaveData();// 2. 事件存档状态game_event_saveLoadEventPrerequisiteData();// 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_creatureGameEventGameobjectGuids[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 currenttimeGameTime::GetGameTime().count();uint32 nextEventDelaymax_ge_check_delay;// 默认 1 天std::setuint16activate,deactivate;for(uint16 itr1;itr_gameEvent.size();itr){if(CheckOneGameEvent(itr))// 该不该开{if(!IsActiveEvent(itr))activate.insert(itr);// 该开但没开 → 加入激活队列}else{if(IsActiveEvent(itr))deactivate.insert(itr);// 该关但没关 → 加入停用队列}calcDelayNextCheck(itr);if(calcDelaynextEventDelay)nextEventDelaycalcDelay;// 取最近的下次检查时间}// 先激活再停用——避免消了又生的闪烁for(autoitr:activate)StartEvent(*itr);for(autoitr:deactivate)StopEvent(*itr);return(nextEventDelay1)*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:returnStartcurrenttimecurrenttimeEnd(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*creaturenewCreature;creature-LoadCreatureFromDB(guid,map);}}// 2. 同理处理 GameObject// 3. 激活关联的生成池Poolfor(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 eventIditr-second.EventId;uint32 conditionitr-second.Condition;floatnumitr-second.Num;// 这个任务贡献多少进度// 事件必须处于 CONDITIONS 阶段才计进度if(!IsActiveEvent(eventId))return;if(_gameEvent[eventId].State!GAMEEVENT_WORLD_CONDITIONS)return;// 累加进度不超过上限citr-second.Donemin(citr-second.Donenum,citr-second.ReqNum);// 检查是否所有条件都满足了if(CheckOneGameEventConditions(eventId)){// 切换到 NEXTPHASE保存状态到 DB_gameEvent[eventId].StateGAMEEVENT_WORLD_NEXTPHASE;SaveWorldEventStateToDB(eventId);sWorld-ForceGameEventUpdate();// 立刻触发下次 Update}}}这段代码的妙处推拉结合。进度靠推送玩家交任务时立即累加阶段切换靠拉取Update 主循环检测到条件满足后切换状态。推送保证了实时性拉取保证了状态一致性。六、conditions 表与 GameEventMgr 的关系上一篇的 conditions 系统和本篇的 GameEventMgr 是两个独立的系统但有关联点CONDITION_ACTIVE_EVENTconditions 表里有一个条件类型专门判断某个 GameEvent 是否激活——ConditionType12, Value1event_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 服务器处理临时内容的标准范式。暴雪官方服务端用同样的思路只是数据表名字不同。本质上是用空间换安全宁可多存一组事件专属的数据也不冒险修改常驻数据。事件系统像一个舞台总监——它不演戏但它决定什么时候拉幕、什么时候换景、什么时候上群演。它和数据层配合让一个静态的世界按时间表活起来。