怀疑是最强大的敌人。 --剑圣
每个游戏,几乎都有任务系统,比如王者荣耀,它有成长任务,成就系统,战令系统,每日任务,还有活动任务。这些本质都是任务系统,其余的游戏,或多或少,都有其一二种。一个游戏的任务系统,应该如何设计实现呢?
我们可以先想想游戏里一些任务的大致内容,比如:
1.等级升至15级。
2.总计获得10万金币。
3.使用战士完成三局游戏。
4.与好友组队达到100把。
5.获得王昭君。
……
看到这些任务的时候,我们应先思考以下几点:
1.它们的触发点是在哪里?
比如等级升至15级,那应该是在获得经验的时候,如果获得经验升级了,则可以触发这个任务了;总计获得10W金币,是在获得奖励的时候,不管哪里获得奖励,只要是金币,就去触发计算是否累积获得;使用战士完成三局游戏,应该是在进入游戏或游戏结算时触发,把本局的职业带过去检验;与好友组队100把,也可以在进入游戏或游戏结算时触发,如果是与好友组队的,则触发;获得王昭君,也可以在获得奖励时触发,检查奖励的实质内容有没有包含指定的奖励。
2.代码应该如何区分这些不同的任务?
程序和策划应该对不同类型的任务都约定一个指定任务类型targetType。比如升级,程序和策划约定该种任务的类型为1;类似的,总计获得金币,约定任务类型为2;使用战士,约定任务类型为3;好友组队,约定任务类型为4;获得英雄,约定任务类型为5。
3.如何提高任务的通用性?
提高任务的通用性,就是要想策划以后可能的扩展改动。比如总计获得10W金币,如果策划以后要改成总计获得10W钻石呢?难道策划和程序都再新增一个任务类型吗?虽然可以这么做,但是程序又得加代码了,所以我们代码不如再做进一步区分,新增个targetId,比如1指金币,2指钻石,那么具体累积获得哪种货币,或以后新增货币,也只需策划填入即可,我们只需在代码里判断传递过来的货币类型和任务配置中的类型是否一致就可达到只写一次代码就可以适应策划各种货币配置的目的,值得注意的是,这里的货币类型定义,应该与玩家的属性定义保持一致,不然还得作映射转换,太麻烦了。比如这里1指金币2指钻石,但是玩家属性早已定义101是指金币,102指钻石,那这里还需要有个1->101,2->102的映射才行,还不如直接在targetId中填101指金币,102指钻石和玩家属性定义保持一致,免得再转一次。
同理,使用战士完成三局游戏,以后策划很可能会再要求使用法师完成三局游戏的,如果任务类型为3专指使用战士完成游戏次数,那么改成使用法师时代码又得新增个任务类型了,同理可以在targetId填入职业类型,比如1指战士,2指法师等,同理这里的职业类型也应当与游戏定义的职业类型保持一致,免得再转。
获得指定英雄,也与上述一样的道理,要做好配置的通用性。免得以后再动代码。
通过第2和3点可以设计任务配置如下:
任务配置.png
接下来看代码如何实现:
在第1点中,我们知道了任务在哪里触发的,那代码该如何写呢?比如在获得奖励的逻辑里,我们是取出身上所有的任务数据出来,一个个循环与任务配置对比,然后设置任务进度吗? 如(错误示范):
public void reward(long rid, List<Item> rewards){
List<Task> tasks = taskService.getAllTasks(rid);
for(Item item : rewards){
if(item.getItemType() == ItemType.PROPERTY){//如果奖励是属性类型
player.getPropertyData().addProperty(item.getItemId(), item.getNum());
for(Task task : tasks){
TaskConfig config = TaskConfig.getConfig(task.getTaskId());
if(config == null){
log.error("任务配置不存在, taskId:{}", task.getTaskId());
continue;
}
if(config.getTargetType == TaskDefine.TARGET_TYPE_TOTAL_MONEY){
if(item.getItemId() == config.getTargetId()){
task.setNum(task.getNum() + item.getNum());
if(task.getNum() >= config.getTargetNum()){
task.setStatus(TaskStatus.DONE_UNREWARD);//完成未领取
}
}
}
}
}else if(item.getItemType() == ItemType.HERO){//如果奖励是英雄类型
player.getHeroData().addHero(item.getItemId(), item.getNum());
for(Task task : tasks){
TaskConfig config = TaskConfig.getConfig(task.getTaskId());
if(config == null){
log.error("任务配置不存在, taskId:{}", task.getTaskId());
continue;
}
if(config.getTargetType == TaskDefine.TARGET_TYPE_GET_HERO){
if(item.getItemId() == config.getTargetId()){
task.setNum(task.getNum() + item.getNum());
if(task.getNum() >= config.getTargetNum()){
task.setStatus(TaskStatus.DONE_UNREWARD);//完成未领取
}
}
}
}
}
}
}
虽然这代码还可以优化,但这种做法肯定是不可取的,触发任务和发奖逻辑耦合到一起了,游戏里触发任务的地方有很多,而任务又有很多个,这样在效率上来说也是不可取的,如果以后新增任务类型,找起来也麻烦,有可能遗漏不说,还可能需要修改代码,非常不好维护。
因此,要换种思路,我们应根据任务目标类型targetType,任务目标Id targetId去寻找是否有触发的任务,即在任务模块提供触发任务的接口,供其他模块调用,如(正确示范):
public class TaskService{
public void triggerTask(long rid, int targetType, int targetId, int num){
List<Task> tasks = getTasks(rid, targetType, targetId);
for(Task task : tasks){
if(task.getStatus() == TaskStatus.DONE_REWARD || task.getStatus() == TaskStatus.DONE_UNREWARD){//已完成已领奖的不再触发
continue;
}
TaskConfig config = TaskConfig.getConfig(task.getTaskId());
if(config == null){
log.error("任务配置不存在, taskId:{}", task.getTaskId());
continue;
}
task.setNum(task.getNum() + item.getNum());
if(task.getNum() >= config.getTargetNum()){
task.setStatus(TaskStatus.DONE_UNREWARD);//完成未领取
}
}
}
}
这样,在发奖时就可以这样写了:
public void reward(long rid, List<Item> rewards){
GamePlayer player = playerService.getPlayer(rid);
for(Item item : rewards){
if(item.getItemType() == ItemType.PROPERTY){
player.getPropertyData().addProperty(item.getItemId(), item.getNum());
taskService.triggerTask(rid, TaskDefine.TARGET_TYPE_TOTAL_MONEY, item.getItemId(), item.getNum());
}else if(item.getItemType() == ItemType.HERO){
player.getHeroData().addHero(item.getItemId(), item.getNum());
taskService.triggerTask(rid, TaskDefine.TARGET_TYPE_GET_HERO , item.getItemId(), item.getNum());
}
}
}
这才是任务系统的正确实现逻辑。即应该由任务目标类型targetType,任务目标id targetId去触发它所有能触发的任务,而不是把所有任务都取出来一个个对照。
这种实现,任务的缓存模型应该为如下形式,一个触发任务的缓存,用于服务端任务的触发;一个请求领取任务奖励的缓存,因为客户端是以任务id请求领奖的。而不是仅一个总任务缓存:
//触发任务缓存
//targetType -> targetId -> Set<Integer> taskSet
Map<Integer, Map<Integer, Set<Integer>> triggerTasks = new HashMap<>();
//或者 targetType_targetId -> Set<Integer> taskSet
Map<String, Set<Integer>> triggerTasks = new HashMap<>();
//所有任务缓存taskId -> Task
Map<Integer, Task> tasks = new HashMap<>();
拓展及优化:
因为一个游戏中可能有多个任务系统,如成长任务,成就系统,战令系统,每日任务,活动任务,这些系统归根结底都是任务系统,各个系统分开写也可以,因为毕竟任务配置可能不在同一个文件中,可能配置的字段数量也不一样,还有存储的数据库表也可能不在同一个表,请求的协议也可能不一样,只要把代码复制一遍就可以实现了,但是如果一个任务系统的代码写错了,那其他任务系统的代码就都得改,所以好的方案是尽量把它们做成一个总的任务系统,此后添加其他的任务系统,只需修改少量代码即可。
现在我们就假设这些任务系统的配置都不共用一张配置表,任务的数据db存储表也不是同一个表,任务的请求协议也不一样,那又该如何改动适配呢?
任务触发的方式还是和上面一样的,只是任务的缓存模式改变一下就可以了。如:
//触发任务缓存
//targetType_targetId -> Set<taskId_taskType>
Map<String, Set<String>> triggerTasks = new HashMap<>();
//所有任务缓存 taskType -> taskId -> Task
Map<Integer, Map<Integer, Task>> tasks = new HashMap<>();
嫌字符串拼接不好的,也可以用Pair:
//触发任务缓存Pair<targetType,targetId> -> Set<Pair<taskId,taskType>>
HashMap<Pair<Integer, Integer>, Set<Pair<Integer, Integer>>> triggers = new HashMap<>();
或者合并两个int为long:
HashMap<Long, Set<Long>> triggerTask = new HashMap<>();
public static long intMergeToLong(int hig, int low){
long value = 0L;
value = ((long) hig) << 32;
value |= low;
return value;
}
public static int getLongHig(long value){
return (int) (value >> 32 & 0xffffffff);
}
public static int getLongLow(long value){
return (int) (value & 0xffffffff);
}
然后取不同任务系统里的任务数据时,可以定义枚举来做,比如取不同的配置,取不同db表的数据,存储不同db表的数据,方案有很多,自行择优实现即可。
网友评论