任务系统

常规需求分析

任务系统是RPG、模拟经营等游戏引导玩家推进剧情、体验内容的核心系统。以下是这类系统普遍需要覆盖的需求边界。

任务基础属性:唯一id、多语言名称/描述、任务类型(主线/支线/日常/隐藏)、优先级(UI排序)、重复接取上限(0或负数表示无限制)。

任务状态机

1
2
3
NotStarted → InProgress → Completed

Failed → NotStarted(失败不计入接取次数,可重置重接)

任务接取:支持手动接取(对话/交互触发AcceptQuest(id))和自动接取(游戏内事件匹配后自动触发)。接取前可配置前置条件列表(ConditionManager拦截),接取时可配置事件列表(ActionManager执行)。

任务目标:支持多种目标类型(播放对话、进入场景、收集道具……)且可扩展,目标有进度值和完成状态。多目标支持线性激活(Sequential,完成一个再激活下一个)和并行激活两种模式,每个目标完成时可配置独立事件列表。

任务失败:失败条件只在InProgress期间生效,任务结束后立即解除监听。失败后触发失败事件列表,重置后可重新接取。

任务提交:所有目标完成后触发提交事件列表。奖励、剧情、解锁新任务均通过ActionManager执行,任务模块本身不持有奖励逻辑。

存档:需持久化任务状态、目标进度、已接取次数、当前追踪任务id;读档后必须完整恢复目标事件订阅和失败触发器监听,确保进行中任务行为一致。

UI接口:按状态筛选/排序任务列表、追踪任务进度实时刷新、接取/完成/失败事件通知。


传统实现的痛点

  • 目标类型扩展困难:新增类型需要修改核心switch/if,牵一发而动全身
  • 接取逻辑分散:自动接取散落在各系统中形成隐式耦合
  • 失败条件泄漏:任务结束后事件监听仍存在,存在错误触发和内存泄漏风险
  • 读档后状态不一致:事件订阅是纯运行时状态,读档后未重建则任务卡死
  • 自动接取检测效率低:遍历全部配表任务逐一判断,O(n)复杂度

解决思路:配表驱动 + 注册表模式(目标/触发器工厂字典) + Luban后处理器生成反查表(O(1)自动接取检测) + 严格的激活/失活生命周期管理。


模块拆分

模块 路径 职责
核心流程 QuestManager.cs 生命周期管理,对外唯一入口
任务目标 Objectives/ 各目标类型实现,继承QuestObjectiveBase
自动接取触发器 AutoAcceptTriggers/ 各触发器类型的匹配逻辑,静态方法
失败触发器 FailTriggers/ 各失败条件实现,继承QuestFailTriggerBase
数据模型 Model/ QuestDataQuestObjectiveData,数据序列化

配表设计

任务主表(CfgQuest)

配表路径:Luban/Datas/系统_任务.xlsx

任务主表截图

说明
AutoAeecptType / AutoAeecptParamList 自动接取触发器类型id及参数,null表示不自动接取
AcqCondIdList / AcqActionIdList 接取前条件 / 接取时执行事件
Sequential true=线性激活目标,false=并行激活
FailType / FailParamList 失败触发器类型id及参数,null表示无失败条件
AcpCntMax 重复接取上限,0或负数无限制

任务目标表(CfgQuestObjective)

任务目标表截图

说明
ShowCount 是否UI展示进度统计(如”3/5”)
Type 目标类型id,对应DefQuestObjectiveType中的常量
ObjectiveParamList 目标参数,格式见程序暴露声明sheet

程序暴露声明

程序侧在Excel中维护三张##不导出的说明sheet,供策划查阅已注册的类型id及参数格式:

任务目标类型_程序暴露

任务目标类型

id 描述 参数格式 参数说明
100 播放剧情 string 对话id
101 进入场景 string 场景id
102 获取灵感道具 string, string 道具id, 目标数量

自动接取触发器类型_程序暴露

自动接取触发器

id 描述 参数格式 参数说明
100 任务提交时 string 前置任务id(0=任意任务提交均触发)
101 进入场景时 无参数

任务失败触发器类型_程序暴露

任务失败触发器

id 描述 参数格式 参数说明
100 离开场景白名单 string, string, … 允许停留的场景id列表

配置举例

例1:主线任务链,前一个提交后自动接取下一个

主线任务链配置示例

任务示例101:主线,,任务100提交后自动触发接取(AutoAeecptType=100, AutoAeecptParamList=100)。

例2:支线任务,进入场景自动接取且有失败条件

任务示例:进入场景后自动接取(AutoAeecptType=101),Sequential=false并行激活所有目标,FailType=100, FailParamList=Scene_Library(只在Scene_Library内有效)。

例3:可重复日常任务

任务示例:AcpCntMax=3,存档内最多接取3次。


程序端扩展

关键设计:自动接取触发器与失败触发器的生命周期完全不同。

  • 自动接取触发器QuestManager.OnInit() 时全局常驻监听游戏事件,因为接取任务前任务 runtime 尚未创建,必须由 Manager 级别的全局监听来驱动 AcceptQuest()
  • 失败触发器:跟随任务 runtime 生命周期,AcceptQuest() 时实例化并激活,任务结束(完成/放弃/失败)时统一失活销毁,事件订阅不超出任务存活期

扩展新的目标类型

以新增击杀敌人目标为例:

第一步:DefQuestObjectiveType.cs 添加常量

1
public const int KILL_ENEMY = 103;

第二步:新建 KillEnemyQuestObjective.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[Serializable]
public class KillEnemyQuestObjective : QuestObjectiveBase
{
[ES3Serializable] private string _enemyId;
[ES3Serializable] private int _targetCount;

public KillEnemyQuestObjective(string enemyId, int targetCount) { /* 赋值字段 */ }

public static QuestObjectiveBase Create(List<string> paramList) { /* 解析paramList构造并返回 */ }

public override int GetTargetValue() => _targetCount;

protected override void RegisterEvents()
{
// 订阅 OnEnemyKilled
}

protected override void UnregisterEvents()
{
// 取消订阅 OnEnemyKilled
}

private void OnEnemyKilled(string enemyId)
{
// if enemyId != _enemyId → return
// SetCurrentValue(CurrentValue + 1)
}
}

RegisterEvents / UnregisterEvents 只写订阅逻辑,基类的_isEventRegistered标志位保证不重复注册;进度通过SetCurrentValue()写入,基类自动检查完成并分发事件。

第三步:QuestManager.QuestObjective.csRegisterObjectiveTypes() 中注册

1
RegisterObjective(DefQuestObjectiveType.KILL_ENEMY, KillEnemyQuestObjective.Create);

最后在程序暴露声明sheet补充类型说明。


扩展新的自动接取触发器

自动接取触发器是无状态的纯函数,只做参数匹配判断,不持有任何运行时状态。QuestManager 全局监听游戏事件,事件触发时通过反查表拿到候选任务列表,再用触发器函数逐一匹配。

以新增获得道具时触发为例:

第一步:DefQuestAutoAcceptTriggerType.cs 添加常量

1
public const int ITEM_COLLECTED = 102;

第二步:新建 QuestTriggerOnItemCollected.cs

1
2
3
4
5
6
7
8
public static class QuestTriggerOnItemCollected
{
// paramList[0] = 目标道具id
public static bool AutoAcceptTrigger(List<string> paramList, string collectedItemId)
{
// return paramList[0] == collectedItemId
}
}

第三步:QuestManager.AutoAcceptTrigger.csRegisterAutoAcceptTrigger() 中注册

1
Register(DefQuestAutoAcceptTriggerType.ITEM_COLLECTED, (TriggerMatch<string>)QuestTriggerOnItemCollected.AutoAcceptTrigger);

第四步:QuestManager.Event.cs 监听对应事件并调用 CheckAutoAcceptQuests

1
2
3
4
5
6
7
8
9
10
// RegisterEventListeners() 中:
EventManager.Instance.AddListener<string>(EventName.OnItemCollected, OnItemCollectedEvent);

// UnregisterEventListeners() 中:
EventManager.Instance.RemoveListener<string>(EventName.OnItemCollected, OnItemCollectedEvent);

private void OnItemCollectedEvent(string itemId)
{
CheckAutoAcceptQuests(DefQuestAutoAcceptTriggerType.ITEM_COLLECTED, itemId);
}

最后在程序暴露声明sheet补充说明,重新运行gen.bat更新反查表


扩展新的失败触发器

失败触发器是有状态的实例对象,随任务接取时创建、任务结束时销毁,事件订阅严格限定在任务存活期内。实例会随 QuestData 一起序列化存档,读档后通过 OnReload() 恢复事件监听。

以新增计时超时失败为例:

第一步:DefQuestFailType.cs 添加常量

1
public const int TIME_LIMIT = 101;

第二步:新建 QuestFailTriggerOnTimeLimit.cs,继承 QuestFailTriggerBase

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[Serializable]
public class QuestFailTriggerOnTimeLimit : QuestFailTriggerBase
{
[ES3Serializable] private float _limitSecond;
[ES3Serializable] private float _startSecond; // 激活时记录,读档后自动恢复

public static QuestFailTriggerBase Create(List<string> paramList)
{
// 解析paramList[0]为limitSecond,构造并返回
}

public override void OnActivated(int questId)
{
base.OnActivated(questId);
// 记录 _startSecond = 当前游戏时间
}

protected override void RegisterEvents()
{
// 订阅 OnGameTimeMinuteChange
}

protected override void UnregisterEvents()
{
// 取消订阅 OnGameTimeMinuteChange
}

protected override bool CheckFail()
{
// return 当前时间 - _startSecond >= _limitSecond
}

private void OnMinuteChange()
{
TriggerFail(); // 基类:if CheckFail() → QuestManager.Instance.FailQuest(_questId)
}
}

第三步:QuestManager.QuestFail.csRegisterQuestFailTrigger() 中注册

1
RegisterFailType(DefQuestFailType.TIME_LIMIT, QuestFailTriggerOnTimeLimit.Create);

内部实现

任务生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AcceptQuest(questId)
→ CheckAcqConditions() 检查接取条件
→ GetAcceptQuestData() 创建/重置任务数据
→ ExecuteAcqActions() 执行接取事件
→ ActivateQuestObjectives() 激活目标(按Sequential模式)
→ ActivateFailTrigger() 激活失败触发器
→ Dispatch OnQuestAccepted

目标进度变化 → 检查完成
→ 完成 → ExecuteObjectiveDoneActions() → Dispatch OnQuestObjectiveCompleted
→ DeactivateObjectiveAt()
→ [Sequential] index++ → 激活下一目标
→ CheckQuestCompleted()
→ 全部完成 → DeactivateAll → ExecuteSubmitActions
→ Dispatch OnQuestSubmitted / OnQuestCompleted

失败触发器触发 → FailQuest()
→ DeactivateAll → AcceptedCount-- → ExecuteFailActions → Dispatch OnQuestFailed

目标事件订阅生命周期

QuestObjectiveBase中的_isEventRegistered标志位严格管控订阅状态:

1
2
OnActivated() / OnReload() → if(!_isEventRegistered) → RegisterEvents() → flag=true
OnDeactivated() → if(_isEventRegistered) → UnregisterEvents() → flag=false

所有结束路径(完成/放弃/失败/退出)均通过OnDeactivated()统一取消订阅,所有激活路径(正常接取/读档恢复)通过OnActivated()/OnReload()统一注册,标志位兜底防止重复注册。

自动接取反查表优化

Luban后处理器在导表阶段预生成DefQuestAutoAcceptTypeReverse,将”触发器类型 → 任务id列表”固化为静态只读字典:

1
2
3
4
5
6
7
8
9
10
11
// 由Luban后处理器自动生成,禁止手动修改
public static partial class DefQuestAutoAcceptTypeReverse
{
private static readonly Dictionary<int, List<int>> _data = new()
{
{ 100, new List<int> { 1002, 1003, 2005 } }, // QUEST_SUBMITTED 触发器
{ 101, new List<int> { 2001, 3007 } }, // ENTER_SCENE 触发器
};

public static List<int> GetValues(int triggerType) { /* return _data[triggerType] ?? [] */ }
}

运行时 CheckAutoAcceptQuests(triggerType, ...)GetValues(triggerType) O(1)取候选列表 → 逐一做参数匹配。相比遍历全表的O(n),k(该触发器关联的任务数)远小于n,且反查表在导表阶段已构建,运行时零额外开销。

存档与读档

存档内容:_questDict(任务状态、目标进度、运行时目标对象实例、失败触发器实例、已接取次数)和TrackedQuestId。运行时对象直接序列化,读档后无需按配表重建,保留运行时已积累的中间状态。

读档流程:

1
2
3
1. DeactivateAll(取消全部订阅,清理上次状态)
2. 读取 _questDict 和 TrackedQuestId
3. foreach InProgress任务 → objective.OnReload() + failTrigger?.OnReload()(重新订阅)

注意事项

  • AcpCntMax <= 0 视为无限制;任务失败时AcceptedCount自动-1,失败后仍可重新接取不超限
  • Sequential=true时,ObjectiveList的填写顺序即为目标推进顺序