你站在暴风城门口,一个 NPC 头上亮着黄色感叹号。你走上去点他,他说"我有个任务给你"——但这个感叹号不是谁都能看到的。只有做完前置任务、达到等级要求、阵营对得上的玩家,才会看到它亮起来。
谁在替你做这道判断?不是 NPC 的 AI,不是任务的脚本,而是一个叫ConditionMgr的单例——它从数据库读出条件,在代码里跑一遍,决定你"看得见"还是"看不见"。
上一篇(数据库篇06)聊了conditions表的结构:SourceType、ConditionType、三个 Value、ElseGroup 这些字段怎么排列组合。这篇从代码层面追问:这些数据加载进内存后,谁来用、怎么查、怎么算。
一、数据加载:从 SQL 到内存
加载入口
ConditionMgr是一个标准单例(sConditionMgr),启动时由worldserver调用LoadConditions():
voidConditionMgr::LoadConditions(boolisReload){Clean();// 先清空所有容器QueryResult result=WorldDatabase.Query("SELECT SourceTypeOrReferenceId, SourceGroup, SourceEntry, SourceId, ""ElseGroup, ConditionTypeOrReference, ConditionTarget, ""ConditionValue1, ConditionValue2, ConditionValue3, ""NegativeCondition, ErrorType, ErrorTextId, ScriptName ""FROM conditions");一条SELECT * FROM conditions,把整张表拉进内存。
两条分流:分组 vs 非分组
加载时,每条记录根据SourceType走不同的存储路径:
分组类型(SourceGroup 有意义的)——条件挂在"一组"东西上,比如战利品模板、对话菜单、SmartAI 事件:
| SourceType | 存到哪 |
|---|---|
| 战利品模板(CREATURE/GO/ITEM/FISHING 等12种) | 注入对应的LootTemplate对象 |
| GOSSIP_MENU / GOSSIP_MENU_OPTION | 注入对话菜单数据 |
| SMART_EVENT | SmartEventConditionStore[make_pair(entry, sourceType)][eventId] |
| VEHICLE_SPELL / SPELL_CLICK_EVENT | 专用的二维 Map |
| NPC_VENDOR | NpcVendorConditionContainerStore[creatureId][itemId] |
非分组类型(SourceGroup 无意义的)——条件挂在单个"入口"上,比如任务可用性、法术施放条件:
// 统一三级 Map:SourceType → SourceEntry → ConditionListConditionStore[cond->SourceType][cond->SourceEntry].push_back(cond);两种存储方式的区别:分组条件在加载时就绑到业务对象上(比如 LootTemplate 里的某个掉落项),查询时直接从业务对象取;非分组条件存在全局 Map 里,查询时按SourceType + SourceEntry两个 key 查找。
条件引用(Reference)
ConditionTypeOrReferenceId为负数时,这条记录不是条件,而是引用模板:
if(iConditionTypeOrReference<0)// it has a reference{cond->ReferenceId=uint32(std::abs(iConditionTypeOrReference));// 引用模板存到独立容器ConditionReferenceStore[uRefId].push_back(cond);}引用模板允许你把一组常用条件定义一次,多处引用——比如"等级≥80且完成某个前置任务"这个组合,如果十个任务都要用,写十条引用比复制十遍条件行干净得多。
二、Condition 结构体:一个条件的完整画像
从数据库读出的每一行,被构造成一个Condition对象:
structCondition{ConditionSourceType SourceType;// 挂在哪个系统uint32 SourceGroup;// 分组 ID(战利品模板号/菜单号)int32 SourceEntry;// 具体条目(掉落物品ID/菜单选项ID)uint32 SourceId;// 仅 SMART_EVENT 使用uint32 ElseGroup;// ELSE 逻辑分组ConditionTypes ConditionType;// 判断"什么"uint32 ConditionValue1;// 参数1uint32 ConditionValue2;// 参数2uint32 ConditionValue3;// 参数3uint32 ErrorType;// 失败时的错误码(仅 SPELL 类型)uint32 ErrorTextId;// 错误文本 IDuint32 ReferenceId;// 引用 ID(负数条件类型)uint32 ScriptId;// 脚本 IDuint8 ConditionTarget;// 判断哪个 targetboolNegativeCondition;// 取反};关键字段的作用:
- ElseGroup:实现 OR 逻辑的核心。同一 ElseGroup 内的条件是 AND,不同 ElseGroup 之间是 OR。后面详述。
- ConditionTarget:指定对
ConditionSourceInfo中的哪个对象做判断(0=自己,1=目标,2=第三方),最多 3 个。 - NegativeCondition:结果取反。比如"没有某个光环"就用
CONDITION_AURA + NegativeCondition=true。
三、Condition::Meets()——判断的核心
每个 Condition 对象有一个Meets(ConditionSourceInfo&)方法,这是条件判断的原子操作。
50 种 ConditionType 的分发
Meets()内部是一个巨型switch,50 个 case 分支,每个分支做一种判断:
| ConditionType | 做什么 | 示例 |
|---|---|---|
| CONDITION_NONE | 永远成立 | 占位条件 |
| CONDITION_AURA | 判断有没有光环 | 英雄祝福是否在身 |
| CONDITION_ITEM | 判断背包物品数量 | 有没有5个魔铁矿 |
| CONDITION_ZONEID | 判断所在区域 | 在不在冬泉谷 |
| CONDITION_QUESTREWARDED | 判断任务是否完成 | 有没有做完"通灵学院" |
| CONDITION_LEVEL | 判断等级 | ≥58 级才能进外域 |
| CONDITION_TEAM | 判断阵营 | 部落专属任务 |
| CONDITION_CLASS | 判断职业 | 战士才能接的任务 |
| CONDITION_NEAR_CREATURE | 判断附近有没有某怪 | 附近有没有"卫兵马尔勒" |
| CONDITION_HP_PCT | 判断血量百分比 | 血量低于 30% 才触发 |
| CONDITION_ACTIVE_EVENT | 判断节日活动是否开启 | 暗月马戏团期间 |
截取几段代码感受:
caseCONDITION_AURA:if(Unit*unit=object->ToUnit())condMeets=unit->HasAuraEffect(ConditionValue1,ConditionValue2);break;caseCONDITION_QUESTREWARDED:if(Player*player=unit->GetCharmerOrOwnerPlayerOrPlayerItself())condMeets=player->GetQuestRewardStatus(ConditionValue1);break;caseCONDITION_NEAR_CREATURE:condMeets=static_cast<bool>(GetClosestCreatureWithEntry(object,ConditionValue1,float(ConditionValue2),!ConditionValue3));break;取反与失败记录
Meets()的尾部有两步收尾:
if(NegativeCondition)condMeets=!condMeets;// 取反if(!condMeets)sourceInfo.mLastFailedCondition=this;// 记录哪个条件挂了returncondMeets;mLastFailedCondition的用途:法术施放条件失败时,客户端需要知道"为什么不能放",这个字段指向最后一个失败的条件,从中提取ErrorType和ErrorTextId,返回给客户端显示红字提示。
四、AND/OR/NOT——条件组合逻辑
单条条件只是原子判断,真正的威力在于组合。IsObjectMeetToConditionList()实现了完整的 AND/OR/NOT 逻辑:
boolConditionMgr::IsObjectMeetToConditionList(ConditionSourceInfo&sourceInfo,ConditionListconst&conditions){std::map<uint32,bool>ElseGroupStore;// ElseGroup → 是否通过for(autoconst&cond:conditions){if(!cond->isLoaded())continue;autoitr=ElseGroupStore.find(cond->ElseGroup);if(itr==ElseGroupStore.end())ElseGroupStore[cond->ElseGroup]=true;// 初始假设通过elseif(!itr->second)continue;// 这组已经挂了,跳过后续if(cond->ReferenceId){// 递归展开引用autoref=ConditionReferenceStore.find(cond->ReferenceId);if(ref!=ConditionReferenceStore.end())if(!IsObjectMeetToConditionList(sourceInfo,ref->second))ElseGroupStore[cond->ElseGroup]=false;}else{if(!cond->Meets(sourceInfo))ElseGroupStore[cond->ElseGroup]=false;}}// 任意一个 ElseGroup 通过 = 整体通过(OR)for(autoconst&[group,passed]:ElseGroupStore)if(passed)returntrue;returnfalse;}逻辑翻译成人话:
- 同一 ElseGroup 内:所有条件必须全部通过(AND)。任意一条
Meets()返回 false,整个组标记为失败。 - 不同 ElseGroup 之间:任意一组通过即可(OR)。
- 引用:递归展开,用同一套 AND/OR 逻辑计算。
举一个具体例子:
条件A:ElseGroup=0, CONDITION_LEVEL ≥ 58 条件B:ElseGroup=0, CONDITION_QUESTREWARDED 完成"黑暗神殿" 条件C:ElseGroup=1, CONDITION_TEAM = 部落 条件D:ElseGroup=1, CONDITION_QUESTREWARDED 完成"奥格瑞玛的召唤"计算逻辑 = (A AND B) OR (C AND D)
翻译:要么等级58以上且完成了黑暗神殿,要么你是部落且完成了奥格瑞玛的召唤。这种组合在数据库里靠 ElseGroup 字段来拆分——不需要额外的逻辑表或嵌套结构。
五、谁来调 ConditionMgr?
条件系统不是"推"的(不会主动通知"条件满足了"),而是"拉"的——业务系统在需要判断时,主动查一次。
任务可见性
// PlayerQuest.cppboolPlayer::SatisfyQuestConditions(Questconst*qInfo,boolmsg){ConditionList conditions=sConditionMgr->GetConditionsForNotGroupedEntry(CONDITION_SOURCE_TYPE_QUEST_AVAILABLE,qInfo->GetQuestId());if(!sConditionMgr->IsObjectMeetToConditions(this,conditions)){// 条件不满足,任务不可见}}玩家走近 NPC 时,服务端遍历该 NPC 关联的所有任务,每个任务查一次条件——满足的亮感叹号,不满足的隐藏。这就是"千人千面"任务的底层实现。
法术施放条件
// Spell.cppConditionSourceInfocondInfo(m_caster);condInfo.mConditionTargets[1]=m_targets.GetObjectTarget();ConditionList conditions=sConditionMgr->GetConditionsForNotGroupedEntry(CONDITION_SOURCE_TYPE_SPELL,m_spellInfo->Id);if(!conditions.empty()&&!sConditionMgr->IsObjectMeetToConditions(condInfo,conditions)){// 施放失败,返回 ErrorType 给客户端}法术条件支持双目标判断:ConditionTarget=0判断施法者,ConditionTarget=1判断目标。比如"只能对亡灵施放"这个条件,判断的就是 target 而不是 caster。
战利品掉落条件
战利品条件走分组路径:加载时直接绑到LootTemplate的每个LootItem上。生成掉落时,对每个潜在掉落项检查条件:
// LootTemplate::Process() 内部if(item.conditions){if(!sConditionMgr->IsObjectMeetToConditions(lootOwner,item.conditions))continue;// 跳过这个掉落项}这就是为什么同一个怪,不同玩家看到的掉落不同——不是随机数不同,是条件判断的结果不同。
SmartAI 条件
SmartAI 是条件系统的重度用户:
// SmartScript.cppConditionList conditions=sConditionMgr->GetConditionsForSmartEvent(GetEntryOrGuid(),GetEventId(),GetSourceType());if(!sConditionMgr->IsObjectMeetToConditions(me,conditions))return;// 条件不满足,不执行这个 ActionSmartAI 的每个 Event 都可以挂条件——让 Boss 的某个技能只在特定条件下触发,比如"血量低于 50% 时才用大招"。
六、ConditionTarget:三个锚点
ConditionSourceInfo最多携带 3 个 WorldObject 指针:
structConditionSourceInfo{WorldObject*mConditionTargets[3];Condition*mLastFailedCondition;};ConditionTarget字段(0/1/2)决定当前条件对哪个对象做判断。常见用法:
| 场景 | Target 0 | Target 1 | Target 2 |
|---|---|---|---|
| 任务可见性 | 玩家 | — | — |
| 法术施放 | 施法者 | 目标 | — |
| SmartAI | 自己 | 邀请目标 | — |
| 战利品 | 击杀者 | — | — |
Meets()内部通过object = sourceInfo.mConditionTargets[ConditionTarget]取到判断对象,然后根据ConditionType转型为Unit*/Player*/Creature*再做具体检查。如果目标对象不存在,直接返回 false。
七、数据驱动的设计哲学
回头看整个条件系统,它的核心思路是:把"什么条件下做某事"这件事,从散落在各个系统的硬编码,收敛到一张表 + 一个管理器。
好处:
- 统一查询接口:不管是任务、法术、战利品还是 NPC 商店,全走
IsObjectMeetToConditions(),不用每个系统各写一套判断逻辑。 - 纯数据配置:新增一种条件组合,只插数据库行,不改动 C++ 代码。
- 可组合:AND/OR/NOT + 引用模板,条件可以像搭积木一样拼装。
- 调试透明:
mLastFailedCondition记录最后一个失败条件,定位问题很直观。
代价:
- 全量加载:启动时一次性加载所有条件到内存。条件数据量大(数万条),但不频繁变更,所以内存换速度是划算的。
- 可读性差:数据库里的条件行,不查枚举定义根本看不懂——
ConditionType=5, Value1=72, Value2=5是什么意思?你得知道 5=REPUTATION_RANK,72 是阵营 ID,5 是声望等级掩码。 - 条件类型的扩展成本:新增 ConditionType 需要改
ConditionTypes枚举 +Meets()里加 case +isConditionTypeValid()里加校验——三处联动,比纯数据配置重。
这三点代价里,第三点是最有意思的:条件系统号称"数据驱动",但新增判断类型仍然要改代码。这其实是半数据驱动——组合是数据的,原子是代码的。条件之间怎么拼(AND/OR/NOT)由数据库行决定,但每个原子条件"怎么算"由 C++ 硬编码。这个折中是务实的:组合逻辑变化频繁,放在数据层灵活;原子判断变化少,放代码层稳定高效。
一张表、一个 switch、一个递归——条件系统用最朴素的结构,撑起了游戏里几乎所有的"能不能"判断。它不花哨,但管用。