游戏任务系统实现思路

怀疑是最强大的敌人。 --剑圣

每个游戏,几乎都有任务系统,比如王者荣耀,它有成长任务,成就系统,战令系统,每日任务,还有活动任务。这些本质都是任务系统,其余的游戏,或多或少,都有其一二种。一个游戏的任务系统,应该如何设计实现呢?

我们可以先想想游戏里一些任务的大致内容,比如:
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表的数据,方案有很多,自行择优实现即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352

推荐阅读更多精彩内容

  • 游戏任务是游戏制作中的必须环节,那么游戏任务是怎么完成的呢?游戏任务的制作又有那些需要注意的问题呢?我们一起看看:...
    牧野在册阅读 12,423评论 0 6
  • 曾经十分沮丧, 因为没有鞋子, 可是就在街上, 遇到没有脚的男子。 没有腿还能高兴快乐自信着, 自信富有充满着大脑...
    水日皿君阅读 97评论 0 1
  • 今天回家,爸爸在做饭,我把一些好玩的东西给我弟弟,我和他一起玩彩泥。那个水晶彩泥,刚好可以当我们玩的牛肉。...
    蘑菇卿_5ca9阅读 397评论 0 0