AzerothCore学习笔记·事件02:game_event 叠加层——节日活动是怎么“叠“上去的
2026/7/3 0:51:12 网站建设 项目流程

每年冬天,暴风城和奥格瑞玛会冒出圣诞树、雪人、还有卖节日物品的 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;}

关键设计:

  1. 先激活再停用:代码注释说得很明白——“a now activated event can contain a spawn of a to-be-deactivated one”,先激活后停用,避免同一个 NPC 消失又立刻出现的闪烁。
  2. 动态心跳:返回的不是固定值,而是"最近一个事件变化的倒计时"——如果没有事件即将变化,最长等一天再查。

CheckOneGameEvent:该不该开?

boolGameEventMgr::CheckOneGameEvent(uint16 entry)const{switch(_gameEvent[entry].State){caseGAMEEVENT_NORMAL:returnStart<currenttime&&currenttime<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:nonedisplay: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 ↓ ↓ ↓ 等前置完成 收集条件进度 定时器到→启动后续事件
  1. INACTIVE:检查前置事件是否都完成了(PrerequisiteEvents集合)。只有所有前置事件处于 NEXTPHASE 或 FINISHED 状态,本事件才能进入下一阶段。
  2. CONDITIONS:等待条件满足。GameEventConditionMap记录了多个条件,每个条件有ReqNum(需要多少)和Done(已完成多少)。
  3. NEXTPHASE:所有条件满足,设一个定时器(Length分钟后),到时间后自动进入 FINISHED 并启动后续事件。
  4. 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 是两个独立的系统,但有关联点:

  1. CONDITION_ACTIVE_EVENT:conditions 表里有一个条件类型专门判断"某个 GameEvent 是否激活"——ConditionType=12, Value1=event_id。这意味着任何条件判断(任务可见性、战利品掉落、法术施放)都可以引用游戏事件状态。
  2. SmartAI 的 GAME_EVENT_START/END:事件激活/停用时,RunSmartAIScripts()会触发 SmartAI 的 Hook,而 SmartAI 的事件本身又可以用 conditions 表做条件过滤。
  3. 世界事件条件 vs conditions 表:这是两个不同的"条件"系统——世界事件条件(GameEventConditionMap)是全服进度(比如"全服玩家交了 5000 个任务"),conditions 表是个人条件(比如"这个玩家有没有完成前置任务")。前者驱动事件阶段流转,后者驱动个人可见性。

七、叠加层的本质

回看整个 GameEventMgr,它的核心抽象就是四个字:叠加层

游戏世界的基础内容(常驻 NPC、常驻任务、常驻掉落)是"底层"。游戏事件是一层可以临时贴上去、随时撕下来的"贴纸"——节日 NPC、节日任务、节日商品、NPC 模型替换、战场节日设置,全是贴纸上的内容。

叠加层的设计优势

  • 零侵入:不需要修改常驻数据。暗月马戏团的 NPC 不是"修改"了某个现有 NPC,而是"新增"了一组独立存在的对象。
  • 可逆:停用事件时,只需反向操作——反生成、还原模型、移除商品。底层世界完好如初。
  • 可组合:多个事件可以同时激活,各自叠加自己的内容层。

叠加层的代价

  • 数据膨胀:每增加一个事件,要配套十几张关联表的数据行。一个完整的节日活动可能涉及上百行跨表记录。
  • 调试复杂:某个 NPC 为什么出现了/消失了?答案可能藏在game_event_creature表的正负号里,而不是 creature 表本身。
  • 内存占用:所有事件数据在启动时全量加载。不过事件数量有限(通常几十个),内存开销可控。

叠加层不是 AzerothCore 独创——它是 MMO 服务器处理"临时内容"的标准范式。暴雪官方服务端用同样的思路,只是数据表名字不同。本质上是用空间换安全:宁可多存一组"事件专属"的数据,也不冒险修改常驻数据。


事件系统像一个舞台总监——它不演戏,但它决定什么时候拉幕、什么时候换景、什么时候上群演。它和数据层配合,让一个静态的世界按时间表"活"起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询