游戏策划吧 关注:142,837贴子:806,971

斗地主AI设计分享(长篇)

只看楼主收藏回复

以我对数值策划的浅显体会,我认为数值策划主要做两件事:
一、 why
我为什么要做成这种体验
二、 how
我如何做出这种体验
很可惜,大部分的why我都无法说出逻辑清晰的所以然,于是我只能在鬼蟹的lol平衡性调整文章中看的超级爽,但说不出什么来。
不过,以斗地主为例的话,就很容易说清楚这个why了,我要做的就是尽可能把AI做的像真人一样,让玩家觉得时刻都能找到“人”陪他玩。
于是,顺理成章可以进入我比较擅长的how的部分了。
首先,how对应的对象是机器人、是程序、是死物,对它们而言,是没有“感觉不对”、“感觉慢来”这类的说法,对他们而言只有条件判断if和数值(数字)。以斗地主为例,我们要把平常的出牌经验,全部抽象成为数字,例如我说我的牌好,要转化成分数高;出这手牌好,要转化成为,我(或者我的队友)的分数变高了,或者对手的分数降低了;这手牌能不能出,要转化为,出了之后收益多大,但让对手胜利的概率又有多大;等等。
这篇文章讲的就是
1. 如何把场上的条件转化为数字(数值体系的建立)
2. 遇到很难转化的情况如何额外处理(附加判定机制)
3. 前期(整个游戏不能跑起来之前)如何调整数值
4. 后期(可以亲身玩游戏之后)面对各种疑难杂症,又如何调整数值,甚至推翻原来的体系。
5. 附加内容:做出一个能跑起来的斗地主程序,有发牌、叫地主、出牌直到一局结束。
(注:由于目标是嵌套到已存在的斗地主程序里面,而程序员希望他要做的事情越少越好,所以这个斗地主是一个不完整版,它缺少记录出牌流程并加以分析的模块,只有对应当前的状态,选择合适的出牌策略的流程。不过测试结果已经比较接近真人的行为了,完整版将会在未来补全。并且牌的数值代号是以他的规则定义的,而不是最合适AI设计的定义,所以需要有一些转换的函数,只是看起来同一张牌有多个数值定义,但对实际功能是没有任何影响的)


1楼2017-12-31 21:04回复
    第一步:定义我们需要的基础信息(注:现在只是罗列,之后会逐行解析)
    // Pai 手牌的信息
    type Pai struct {
    handcard []int //原始的牌101-1402
    initstate InitState //初始状态
    arrstate []FinalState //基本遍历所有情况
    bestcardtype [][]int //最好的手牌分配
    bestscore int //最好的手牌分配的分数
    tactics int //确定出牌的策略,1:优势牌全攻,2:中势牌半攻半守,3:劣势牌,全防守
    dizhuseat int //地主的位置(我的位置是0,下家的位置是1,上家的位置是2)
    myseat string //我对应地主的位置
    bigcardscore int //手上大牌的分数,用来确定要不要叫地主
    }
    原始的牌是101-104(四个A),1301-1304(四个K),1401小王,1402大王。
    设计是很清晰明了的,但是我们AI是不看花色的,只分大小王,而且A是比K大的。
    所以我们的排序将成为3-13,14=A,15=2,16=小王,17=大王
    (注:即3,4,5,6,7,8,9,10,11,12,13,14,15,16,17)
    当然这样的分配也有个问题,顺子是不能A2,2小王这样顺的,不过我们会在函数里面做判断。
    同时,我们还要转化为一个数组,把牌放进入。
    arrpai [15]int 我们一共15个大小的牌,找15个盒子把他们装起来,盒子最少装0个,最多装4个。
    虽然这样我们看起来不够清晰,但是增删改查起来要更方便,这是我们最常操作的数据了。
    第一步,我们要让AI学会“看牌”,就是把牌分好类让他记住。


    5楼2017-12-31 21:09
    回复
      // InitState 手牌的初始牌型分布
      type InitState struct {
      arrpai [15]int //把手牌变成数组,方便统计
      shunzi5 []int //只记录最小的那张牌,例如[3,4,5,6,7]只记录3,变成[3],后续再append
      shunzi6 []int
      shunzi7 []int
      shunzi8 []int
      shunzi9 []int
      shunzi10 []int
      shunzi11 []int
      shunzi12 []int
      liandui3 []int
      liandui4 []int
      liandui5 []int
      liandui6 []int
      liandui7 []int
      liandui8 []int
      liandui9 []int
      liandui10 []int
      sanshun2 []int
      sanshun3 []int
      sanshun4 []int
      sanshun5 []int
      sanshun6 []int
      zhadan []int
      arr [22][]int //0-7存放顺子,8-15存放连对,16-20存放三顺(三顺一般都进入飞机组了),21放炸弹
      state FinalState //把上面的,信息,归纳进入最终状态


      6楼2017-12-31 21:09
      回复
        之前,我们讲到计算当前牌型的score的问题,有前面的铺垫,终于走到本主体的核心部分了。
        说点题外话,之前我发现,市面上成功项目的系统公开、技术公开还有一些,不过相关的数值体系公开真的少之又少。当时很是气愤,找一些特别想知道的信息怎么就那么难呢?
        然而,做完这个AI设计第一版,测试效果还不错的时候。我突然觉得,整个代码公开是没所谓的(当然,写的太丑,没什么技术成分是关键),但是那些数字,每一个都是我玩几千把斗地主的心血,每改一个数字,都又几十把过去了。不舍得把他们交给别人(是嫁女儿的感觉么?)
        但是,后来想通了,以后会写出更多、更完善、更有价值的数值设计的,现在自己看来的宝石,再未来的自己看来也只是一个路边的石头罢了。抛弃过去,才能早点走向未来。
        这时候就离不开代码的展示了,整个体系基本都在这一段代码里面


        8楼2017-12-31 21:10
        回复
          // 注:这是第一版的,数值效果。可以说是凭感觉随时写出来的,反正当时想着先出来,再慢慢改嘛。
          // 把牌型算分,配合上面的找出牌型
          if len(i) == 1 { //单牌的算分
          switch i[0]{
          case 3, 4,5, 6:
          score= -100
          case 7, 8,9, 10:
          score= -80
          case 11, 12:
          score= -55
          case 13:
          score= -35
          case 14:
          score= -15
          case 15:
          score= 30
          case 16:
          score= 60
          case 17:
          score= 100
          default:
          }
          }


          9楼2017-12-31 21:12
          回复
            if len(i) == 2 { //对子的算分
            switch i[0]{
            case 3, 4,5, 6:
            score= -95
            case 7, 8,9, 10:
            score= -75
            case 11, 12:
            score= -55
            case 13:
            score= -25
            case 14:
            score= -10
            case 15:
            score= 60
            default:
            }
            }


            10楼2017-12-31 21:12
            回复
              if len(i) == 3 && i[0] == 15 { //三张2的算分
              score = 90
              }
              if len(i) == 4&& i[0] == i[3] { //炸弹的算分
              score = 120
              }
              if (len(i) == 4&& i[0] != i[3]) || (len(i) == 5 && i[0] == i[1]) { //三带2或者三带1算分
              switch i[0]{
              case 3, 4, 5,6:
              score= -60
              case 7, 8,9, 10:
              score= -35
              case 11, 12,13:
              score= -10
              case 14:
              score= 25
              default:
              }
              }


              11楼2017-12-31 21:12
              回复
                if len(i) == 5 && i[0] != i[1] { //顺子5个的算分
                switch i[0]{
                case 3, 4,5, 6:
                score= -40
                case 7, 8:
                score= -20
                case 9:
                score= -05
                case 10:
                score= 10
                default:
                }
                }
                return score


                12楼2017-12-31 21:13
                回复
                  1. 这个的思路核心就是一张3(小牌)和一张大王(绝对大牌)是等值的。
                  (注:后来有改动)
                  并且以手上的牌能否获得收益为价值依据,例如一张A虽然很大可能能打出去,但是不太可能获得上手机会,所以价值是负的,我是大部分时候宁愿少这张牌的。
                  而三个A带一,则被打的几率很小,上手的几率很大,我可以接受手上多这张牌的,所以判断为正分。
                  2. 只简要的计算区段,不作明显的细分
                  (注:这样建模方便,但是最终肯定要没一张牌细分的,从实际效果看来,不细分还凑合)
                  3. 对子要比单牌稍强,实际战斗中,管上单牌的概率比管上对子的要高,但主要还是看大小。
                  4. 6张以上的牌型,基本认定为没机会管上,也不会被管的牌型,所以他们是0分,
                  (注:实际不仅仅能用0分来概括的,但大部分实战确实能简化)
                  5. 三带1(2),分值比单牌对子远远提升,而且涨幅很高,例如三个A已经从负分转为正分了。
                  6. 炸弹的分大约是大王的120%,不分炸弹大小。
                  (注:这个设置确实差不多了,炸弹吃大王确实是一个数值陷阱,让人觉得炸弹好厉害,但实际效果并不是那么强,高估炸弹很容易有不合理的分数判断)


                  13楼2017-12-31 21:13
                  回复
                    这个是测试后,通过反馈的问题优化的函数
                    // 把牌型算分
                    if len(i) == 1 { //单牌的算分
                    switch i[0] {
                    case 3, 4, 5,6:
                    score= -100
                    case 7, 8, 9,10:
                    score= -80
                    case 11, 12:
                    score = -55
                    case 13:
                    score= -35
                    case 14:
                    score= -15
                    case 15:
                    score= 35
                    case 16:
                    score= 70
                    case 17:
                    score= 110
                    default:
                    }
                    }


                    14楼2017-12-31 21:13
                    回复
                      if len(i) == 2 { //对子的算分
                      switch i[0] {
                      case 3, 4, 5,6:
                      score= -95
                      case 7, 8, 9,10:
                      score = -75
                      case 11, 12:
                      score= -55
                      case 13:
                      score= -25
                      case 14:
                      score= -5
                      case 15:
                      score= 70
                      default:
                      }
                      }


                      15楼2017-12-31 21:14
                      回复
                        if len(i) == 3&& i[0] == 15 { //三张2的算分
                        score = 105
                        }
                        if len(i) == 4&& i[0] == i[3] { //炸弹的算分
                        score = 150
                        }
                        if (len(i) == 4&& i[0] != i[3]) || (len(i) == 5 && i[0] == i[1]) { //三带2或者三带1算分
                        switch i[0] {
                        case 3, 4, 5,6:
                        score= -59
                        case 7, 8, 9,10:
                        score= -29
                        case 11, 12,13:
                        score= -9
                        case 14:
                        score= 25
                        default:
                        }
                        }


                        16楼2017-12-31 21:14
                        回复
                          if len(i) == 5&& i[0] != i[1] { //顺子5个的算分
                          switch i[0] {
                          case 3, 4, 5,6:
                          score= -40
                          case 7, 8:
                          score= -20
                          case 9:
                          score= -5
                          case 10:
                          score= 10
                          default:
                          }
                          }
                          return score
                          }


                          17楼2017-12-31 21:14
                          回复
                            我们对比一下改变的数值。
                            1. 2、小王、大王的分值提高了。
                            原因:看起来都是一手牌换一手牌,打一个3,回一个王,又回到起点了。
                            但其实,大牌上手并不一定要出小牌,可能会出一些长牌,留小牌最后出。这样小牌的价值就可以忽略了。
                            也就是总体而言,能上手的牌的价值有额外加成。
                            同样的,2+小王 <大王,虽然很多时候2+小王也可以上手一次,但是没有大王那样一锤定音。
                            2. 对2的价值因为单牌提高而提高了,因为对2拆开来也是很强大的。
                            但是对A的价值却逆增长,因为对A能称为大牌的时候,远比单张A多很多。而且拆对2很正常,拆对A是不够合理的。未来出牌的时候会有拆牌的需求出现,这时候就要在定分的时候调整了。
                            3. 三带1(2)的算分出现不整齐的数字(9),由于是第一版,所以数字都按爱好用5,10表示。
                            而三带1为什么要特殊出现9呢?
                            因为三带1在前期是小牌,被管上的可能性很大。
                            但是在后期,三带1就称为长牌了,很有可能称为值为0的牌。所以要做一个标记,让后期出牌能轻易的找出三带1作为清手牌的时机打出。


                            18楼2017-12-31 21:14
                            回复
                              思考:这个策略最大的问题是什么?
                              缺陷1:没有动态调整,每个牌的价值,都会因为时间的推移变得不一样,例如大小王出完了,2的价值就提升了。应该要设计一个函数,通过计算各玩家出牌流程,然后调整手牌的价值。不过由于现在这个版本是没有记录功能的,所以动态调整将会在出牌(跟牌)的函数建立额外的判断条件。
                              缺陷2:手牌的价值,除了大小以外,还有牌型数的概念,即最少能出几次牌才能打完。例如到你出牌了,你宁愿剩下1张3,而不是一张A加一张2。而我把他们合在一起统计价值了。同上,如果有记录的话,把价值拆分,反而更好的调整两者的价值变化(注:大小价值变化慢,牌型数价值提升快)
                              结尾语,这一篇是大体的介绍了整体的策略变动和缺陷,下一篇将会讲解:
                              有分数了,你怎么利用,还需要添加哪些特殊变化的处理。
                              按这个策略实际打牌时会出现哪些问题。
                              这些问题如何分析,如何处理。


                              19楼2017-12-31 21:15
                              回复