使用遗传算法计算最终幻想战略版(gba)的最佳地图摆放

前言

  最近闲着无聊,捡起GBA游戏的最终幻想战略版,打算再来一次童年重温。

  游戏有这样一个设计,其大地图的每个地点除了几个固定的,其余都是由玩家自己摆放:

 

图一

  每个连接点的中心,可以进行多次宝物探索判定,进而在该地点可以探索到对应摆放生成的宝物:

图二

  小时候最早是按自己感觉乱摆一通,后来知道了攻略网后,基本按着攻略给的例子来。但是作为一个玩家,对于更优的摆放思路一直有着探索的欲望。现在作为一个程序猿,也作为一个强度党,决定利用自身的优势,编写程序,使用算法来自动寻找最佳的摆放方式


 

 问题

  注:这个游戏的细节可以去天幻看,上面的截图也来自于这里。

 

  • 存在一张地图,总计30个点,固定的点有3个,其余可通过下面的旗子随意放置:
    • 街道            4个
    • 山脉            4个
    • 森林                 4个
    • 沙漠                 4个
    • 平原                 4个 3个
    • 洞穴            2个
    • 河流(湿地)        2个
    • 死街                  3个

    一个地图节点存在2-4个连接点(如图一),连接是固定无法更改的,30个点中,还存在34个点是固定的,分别是街道2,山脉1,平原1(如图一)

 

  • 每个节点会根据最近的连接节点类型,计算宝物,其类型如下:
    • 围着该节点的为4个同类型的
    • 围着该节点的为3个同类型的
    • 围着该节点的为2个同类型的
    • 围着该节点的为2个同类型 + 2个另外同类型的

       不同组合的计算可以同时生效,如一个节点的连接点是【街道x2,山脉x2】, 那么计算时需要同时计算以下三种形式

    • 街道x2
    • 山脉x2
    • 街道x2 山脉x2 

  可查 街道2 = 红色靴, 山脉2 = 金刚绿石,街×2山×2 = 艾斯托莱阿之剑 总计三个宝物会生成在该点。  

  对于所有游戏地图的规则,及摆放一览可以看上面说的天幻攻略。


目标    

  攻略网提供了几个摆放实例,而在其评论区提供了一种更好的摆法:

  依照该摆法,其分数在我实现的应用内可以达到31248的高分,比攻略网的两个实例都要好,那么目标就是超越这个

解决过程

  以下为解决该问题的全过程记录,也不是一开始就想到最终的近似答案,还是一步步慢慢踩坑踩出来的。

  以下涉及的代码提交到了github,有兴趣可以看看

模型设计 

  第一个问题是如何将地图转换为模型,并使用代码进行实现?

  我的思路是,将地图按节点进行编码,从0开始,按一定顺序对节点进行递增排序,如下图,不过图上的标记点会有些暗,勉强看下

 

 

  每个节点的连接集合可以保存为双向链表,即如 [0: 1, 4],[1: 0, 2, 4] 

  对于一个地图,其节点和连接是固定的,但是节点的类型是可变的,可以从空变为其他类型,也可以交换两个节点的集合。

  这里的代码可以看项目里的IGameMap及IMapNode的实现,这里不再贴详细代码。

  这里可以看看其重要的接口方法

IGameMap 
/**
 * 游戏地图接口
 * <p>
 * 最终幻想战略版游戏地图的模型,可对其操作设置移除节点,以计算某个组合下的探索物
 * <p>
 * 该接口支持迭代器{@link Iterator},从0开始进行设置节点,其返回值会跳过固定部分
 * 
 * @author terra.lian
 */
public interface IGameMap extends Iterator<IMapNode> {

    /**
     * 根据索引获取对应的节点
     * 
     * @param index 节点索引
     */
    IMapNode getNode(int index);

    /**
     * 设置地图节点的类型
     * 
     * @param index 地图索引
     * @param nodeType 节点类型
     */
    void setNode(int index, MapNodeTypeEnum nodeType);

    /**
     * 交换两个节点的地图类型
     * 
     * @param indexA 地图节点A
     * @param indexB 地图节点B
     */
    void switchNode(int indexA, int indexB);

    /**
     * 交换两个节点的连接点类型
     * 
     * @param indexA 地图节点A
     * @param indexB 地图节点B
     */
    void switchLink(int indexA, int indexB);

    /**
     * 清空某个节点的类型
     * 
     * @param index 地图索引
     */
    void clearNode(int index);

    /**
     * 计算该组合下的宝物探索
     * <p>
     * 一般该方法仅在所有节点设置完成后调用,每次调用都会重新计算
     * <p>
     * 若在一次匹配之外还需要再获取结果,可使用{@link #getMatchCacheList()}方法
     */
    Map<Integer, List<IMapItem>> match();
}
IMapNode 
/**
 * 地图的节点
 * 
 * @author terra.lian
 */
public interface IMapNode {

    /**
     * 获取地图节点的索引
     */
    int getIndex();

    /**
     * 获取该地图节点的类型枚举
     */
    MapNodeTypeEnum getType();

    /**
     * 设置该地图节点的类型枚举
     */
    void setType(MapNodeTypeEnum type);

    /**
     * 获取连接的地图节点,最多4个
     */
    List<IMapNode> getLinks();

    /**
     * 设置连接的地图节点
     * 
     * @param mapNodes 地图节点,支持多个
     */
    void setLinks(IMapNode... mapNodes);

    /**
     * 清空变化的量,如地图节点
     */
    void clear();
}

  

  有了地图模型和节点模型,就可以模型进行计算,而对于探索的宝物,应该如何定义?

  我的思路是,为每个宝物定义一个分值,如普通的红色鞋28,极其稀有的原质宽剑50,类似一个分级,垃圾,普通,史诗,传说....

  但是实际运用后发现,对于宝物分值的定义,分值至少需要根据分级加0,如:

    • 垃圾,普通 0 ~ 30
    • 史诗    300 ~
    • 传说    4000 ~  

  这样做的目的是为了突出越稀有越想要的宝物,否则普通的分值累加都能达到传说的程度,导致分级设置不明显。 

 

IMapItem 
/**
 * 地图摆放可探索的宝物
 * 
 * @author terra.lian
 */
public interface IMapItem extends Comparable<IMapItem> {

    /**
     * 宝物名称
     */
    String itemName();

    /**
     * 宝物的主观评分
     * <p>
     * 根据该数值可评价一个宝物的好坏,评分越高越好
     */
    Integer itemPoint();

    /**
     * 统计宝物的总评分
     * 
     * @param items 宝物集合
     */
    static Integer sumItemPoint(List<IMapItem> items) {
        // 过滤重复项,令不同的好装备才能提升适应度
        return items.stream().distinct().mapToInt(IMapItem::itemPoint).sum();
        // return items.stream().mapToInt(IMapItem::itemPoint).sum();
    }
}
IMapItemMatcher 
/**
 * 地图探索宝物匹配器
 * 
 * @author terra.lian
 */
public interface IMapItemMatcher {

    /**
     * 匹配节点产生的对应宝物
     * <p>
     * 以节点为中心,将其连接的所有节点类型作为条件,判断中心会生成的宝物
     * 
     * @param mapNode 地图节点
     */
    List<IMapItem> match(IMapNode mapNode);
}

 

  这样,我们就有了对应的所有模型,剩下的就是如何搜索。   

    

暴力遍历(错误方案)

  最早的思路是直接写个进行暴力遍历,基于回溯法来计算所有情况,不就是小小的排序么,让我直接遍历它。

  这里代码就不贴了,直接说结论

  运行一个小时都没跑完固定标号0时的所有排序。

  为何?可以通过数学来计算其的所有排序总数,总计27个节点,其中每个节点都存在重复节点,其公式如下:

 

  没计算错误的话,20个数字,这个数量级基本没法通过个人PC的条件来暴力遍历,能算到天荒地老,真是小看了增加个数的排序组合。

     那么该怎么做?

 

遗传算法

  既然无法遍历所有结果,甚至于其中一部分都很难。对于这种需要优化搜索树的算法,在我这边的第一选择就是遗传算法,因为我用过

  嘛,选择这个算法并没有什么特别的理由,处理这种场景相似的算法应该还有很多,这里仅讨论遗传算法的实现。

  想了解遗传算法是什么,可以看这个博客,我对遗传算法的理解也基于此,后面假定你已对遗传算法有了一定了解。

    

  而遗传算法的实现,这里直接用的开源代码,这里使用的是AForge.NET,一个机器学习相关的C#类库,代码及注释,样例都很不错。咱曾经拿来做过毕设

  我将其中的部分神经网络及遗传算法的代码进行移植,使用java版本重新实现,github上的库,后面使用该库在java上实现。迁移不难,c#和java用起来很像 

  

  这里简单讲下这个库的遗传算法的实现

  该库对遗传算法定义了几个接口

  • IChromosome        染色体接口,交叉和突变的实际实现通过该接口的实现类型
  • IFitnessFunction   适应度函数接口,计算染色体的适应度,库内提供了多种适应度函数,也可以自己实现。
  • ISelectionMethod    选择函数接口,用于kill掉不适应的染色体,保留优秀的染色体,库内同样提供了多种通用接口。

  而种群的实现如下,这里展示了其主要方法的实现: 

/**
 * 染色体的种群(Population).
 * <p>
 * 该类表示一个种群 - 收集个体(染色体)并提供实用及公共的种群生命周期 - 借助遗传算子来扩大种群数量,
 * 借助选择算法对染色体进行选择,生成新一代的染色体. 该类可以与任意染色体{@link IChromosome}接口实例
 * 一起工作,并使用任意适应度函数(Fitness Function){@link IFitnessFunction}接口实例及使用任意
 * 选择算法{@link ISelectionMethod}接口实例.
 * 
 */
public class Population {

    /**
     * 初始化一个种群{@link Population}实例.
     * <p>
     * 创建一个指定大小的种群. 祖先染色体将作为种群的第一个成员,并使用统一的参数创建剩下的其他成员.
     * 
     * @param size 种群大小初始值.
     * @param ancestor 用于创建种群的先祖染色体.
     * @param fitnessFunction 用于计算染色体适应值的适应度函数.
     * @param selectionMethod 用于选择新一代染色体的选择算法.
     * @throws IllegalArgumentException 指定的种群规模太小.若size小于2,则抛出该异常.
     */
    public Population(int size, IChromosome ancestor, IFitnessFunction fitnessFunction, ISelectionMethod selectionMethod) {
        if (size < 2)
            throw new IllegalArgumentException("Too small population's size was specified.");

        this.fitnessFunction = fitnessFunction;
        this.selectionMethod = selectionMethod;
        this.size = size;

        // 将祖先添加到种群
        ancestor.evaluate(fitnessFunction);
        population.add(ancestor.clone());
        // 添加更多的染色体到种群
        for (int i = 1; i < size; i++) {
            // 创建新的染色体
            IChromosome c = ancestor.createNew();
            // 计算其适应度
            c.evaluate(fitnessFunction);
            // 添加到种群
            population.add(c);
        }
    }

    /**
     * 执行种群交叉(crossover).
     * <p>
     * 该方法遍历种群并按顺序对每两条染色体执行交叉算子. 配对的染色体总数由交叉概率({@link #crossoverRate})决定
     */
    public void crossover() {
        // 交叉
        for (int i = 1; i < size; i += 2) {
            // 生成下一个随机数,并判断是否需要进行交叉
            if (rand.nextDouble() <= crossoverRate) {
                // 克隆自身及祖先
                IChromosome c1 = population.get(i - 1).clone();
                IChromosome c2 = population.get(i).clone();

                // 执行交叉
                c1.crossover(c2);

                // 对后代计算适应度
                c1.evaluate(fitnessFunction);
                c2.evaluate(fitnessFunction);

                // 将后代添加到种群
                population.add(c1);
                population.add(c2);
            }
        }
    }

    /**
     * 执行种群突变.
     * <p>
     * 该方法遍历种群,对每一个染色体执行突变操作. 突变的染色体总数由突变概率({@link #mutationRate})决定.
     */
    public void mutate() {
        // 突变
        for (int i = 0; i < size; i++) {
            // 生成下一个随机数并判断是否进行突变
            if (rand.nextDouble() <= mutationRate) {
                // 克隆染色体
                IChromosome c = population.get(i).clone();
                // 突变
                c.mutate();
                // 对突变型计算适应度
                c.evaluate(fitnessFunction);
                // 将突变型加入到种群
                population.add(c);
            }
        }
    }

    /**
     * 执行选择.<p>
     * 该方法对当前种群进行选择操作. 使用指定的选择算法对当前种群进行选择作为新种群,并在需要时添加定义数量的随机成员.
     * 参考 {@link #randomSelectionPortion}
     */
    public void selection() {
        // 新种群中的随机染色体数量
        int randomAmount = (int) (randomSelectionPortion * size);

        // 执行选择
        selectionMethod.applySelection(population, size - randomAmount);

        // 增加随机的染色体
        if (randomAmount > 0) {
            IChromosome ancestor = population.get(0);

            for (int i = 0; i < randomAmount; i++) {
                // 创建新染色体
                IChromosome c = ancestor.createNew();
                // 计算适应度
                c.evaluate(fitnessFunction);
                // 添加到种群
                population.add(c);
            }
        }
        
        // 找到种群中迄今为止最好的染色体,会记下来
        findBestChromosome();
    }

    /**
     * 执行一个种群时期(epoch).
     * <p>
     * 该方法执行一个种群时期(epoch),进行交叉,突变及选择.通过调用 {@link #crossover()}, {@link #mutate()} 和 {@link #selection()}.
     */
    public void runEpoch() {
        // 交叉
        crossover();
        // 突变
        mutate();
        // 选择
        selection();
    }
    
}

  染色体种群可以按如下使用

    // 创建自己的适应度函数
    MyFitnessFunction fitnessFunction = new MyFitnessFunction();
    // 创建种群
    Population population = new Population(
            // 种群大小,固定不变
            100, 
            // 我的染色体
            new MyChromosome(),
            // 适应度函数
            fitnessFunction,
            // 选择算法
            new RouletteWheelSelection()
    ); 
    // 设置交叉的染色体概率
    population.setCrossoverRate(0.75);
    // 设置突变的染色体概率
    population.setMutationRate(1);
    // 执行一次迭代
    population.runEpoch();
    // 获取当前最好的染色体
    IChromosome bestChromesome = population.getBestChromosome();

  一次runEpoch会对种群执行一轮交叉,突变,选择。

 

版本一(错误方案)

  这是我是有遗传算法的第一次实现

  该版本的思路如下:

  • 适应度算法:可以简单通过地图摆放匹配出来的宝物价值,通过价值的大小来作为适应值,当然价值越高,适应度越高,适应度算法在所有版本甚至暴力遍历时都没有改过。
  • 选择算法:选择算法倒是每个版本都不同,这个版本我使用精英选择算法,即按适应度由高到低排序,取最高的那部分进行生存。
  • 突变逻辑:直接从可变的27个节点,随机取一个点,再随机取另外一个点,交换2个点,按照场景,我将突变设置为了1,即每次都会进行突变。
  • 交叉逻辑:由于这个场景和交叉的匹配很差,按库的实现,这里我对交叉的定义是,会对父方和母方节点各执行一次突变,作为其子代节点。

 

  既然是错误方案,按照这样实现的方案肯定行不通,那么问题是什么?

  • 突变效率差:由于是随机选择的节点,下次随机并不能保证向更高的山前进,盲目的四处徘徊,虽然通过选择剔除那些价值低的染色体,但是高价值的染色体却并不会变得更高。
  • 选择结果差:通过监测运行状态,我发现由于使用的精英选择,到最后种群中基本都是同一种最好的染色体,其原因是该场景的突变基本都是向更坏的方向进行,然后在该轮被直接清除,导致直接卡死在某个局部高点。

  基于以上的问题,该方案每2万次执行,其最好的结果通常都在2w左右,相对于目标的3万,差距十分明显。

 

版本二(错误方案)

  通过上个版本的实现,发现了几个问题,最主要的就是突变问题,通过仔细思索,认真考量,无言沉默...我设计了以下改进方案,然后就再次扑街了:

  • 解决突变问题:既然随机突变不行,那么定向突变呢?通过分析问题中的探索宝物可以发现,只有成双成对出现的(> 2)才有价值,一个节点若含有某个类型仅1个的节点,那么这个连接点的价值就是0。那么这部分就可以直接优化掉,也就是定向选择这些可以优化掉的节点,随机选择其他节点的连接点中包含该类型的,选其中一个进行交换,也就是定向突变。
  • 解决选择问题:每轮突变生成的子代,由于突变的方向还没进行探索,就被优化掉了达不到进化的目的。这里提出了代和年龄的概念:
    • 一个染色体从年龄0开始,一直增加到最大年龄(即寿命极限)。
    • 将年龄分段为 年轻代,中生代,老年代,每个代有其不同的死亡几率
    • 适应度可以减小死亡几率,以此达到保留高适应度染色体个体的目的。
    • 每轮迭代后,所有染色体年龄+1,选择算法改为按死亡率和适应度进行筛查,符合条件的才死亡。
  • 改进交叉算法:这里将交叉改为,随机取染色体的一个节点,再随机选择一个连接数相同的另一个节点,交换这两者的所有连接点,以此来探索不同组合下的情况。

  上面基本就是版本二的设计方案,看起来还不错,但是的的确确是扑了。

  设计问题最大的是加入代这个概念,导致选择算法出错:由于遗传算法需要保持种群大小固定,所以选择算法需要按比例随机或选择剔除固定部分的成员,但是按我这样的设计,仅按死亡率来判断是否剔除,就导致了种群爆发式增长,数量会快速增长到达到基本没法进行下一轮计算的程度。

  就算通过限制种群数量,当达到某个数量后,就不再调用交叉方法增加种群,突变也不再改变种群大小,年龄这个设计也是败笔,初始化的成员会在寿命结束后一并死亡,若种群设置和寿命一样,会瞬间清空种群所有成员,导致NPE,以及其他各种各样的问题。

  通过最后版本反推猜测,这里的定向突变方案倒是还不错,因此也将结果推到了3w左右的分数,但是一般都是30123左右,没有超越目标。

 

版本三(最终成功的方案)

  上个版本的方案,考虑了方方面面,但是算法复杂度的增加,却没有达到提升效率的目的,形同电脑上的RGB灯效,只能用做晃瞎人的眼球。

  下班后冷静的在道路上飙车思索,回家后在游戏内放松身心,深吸一口洁净的城市废气,静下心来重新阅读遗传算法的原理,发现我的思路错了,果然还是没理解遗传算法的实质,于是重新进行了一版实现,于是就有了最终的这版:

  • 适应度算法:还是取宝物的价值,没有变化 
  • 选择算法:采用库内实现的轮盘选择,也是上面提到的博客内使用的算法
  • 突变逻辑:采用版本二的定向突变,每次选择价值点最小的进行交换
  • 交叉逻辑:直接在该场景不采用。

  很简单的实现,主要逻辑还是其他版本用过的,但是结果很好,可以轻松查找到3w5+的排列结果,算是实现了最终目的。

  基于这个实现,我找到了几个分数要大于目标分数的排列。不过分数由于主观的分数定义,所需的方案可能和你需要的方案不会一致,样例在下面。

 

样例

  下面是一些搜索到的排列,由于没写展示,所以直接输出了排列,可以通过样例比对,但是终究比较麻烦,等我回去把游戏玩了后再说。(咕咕咕)

  

 

来着攻略网的样例摆放,这个地图的摆放是:

[0]沙漠,[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]平原

 

分数:   21363

宝物(重要):   加斯特拉斐迪弓,原质宽剑,敏武宝石,五指短剑,丝带,加尔米亚之靴,宙斯锡杖,超级利刃


 

按照上面来给出更好的方案:

 

方案:这个是目标方案,也就是上面使用最佳值

分数:   31048

宝物:   加斯特拉斐迪弓,原质宽剑,杀意剑,源氏之盾,艾斯托莱阿之剑,五指短剑,加尔米亚之靴,公主守护杖,宙斯锡杖

[0]沙漠,[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]死街


 

 

方案1

 分数:   35213

宝物:   加斯特拉斐迪弓,原质宽剑,敏武宝石,源氏之盔,源氏之盾,源氏护腕,艾斯托莱阿之剑

[0]沙漠,[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]平原


 

方案2

 分数:   35648

宝物:  加斯特拉斐迪弓,原质宽剑,源氏之盔,源氏之盾,源氏护腕,艾斯托莱阿之剑,五指短剑,加尔米亚之靴,公主守护杖

[0]死街,[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]死街


 

 

方案3

 分数:   35608

宝物:  加斯特拉斐迪弓,原质宽剑,敏武宝石,源氏之凯,源氏之盾,源氏护腕,艾斯托莱阿之剑,塔鲁瓦鲁

[0]沙漠,[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]山脉

 


 

方案4   这个版本是含敏武宝石和五指短剑的最佳版本

 

分数:   31293

宝物:  加斯特拉斐迪弓,原质宽剑,敏武宝石,源氏之盾,艾斯托莱阿之剑,五指短剑,塔鲁瓦鲁

[0]沙漠,[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]街道


 

 

结语

因为我还没玩,错了不要怪我

 

posted @ 2022-06-24 19:38  四方田春海  阅读(6502)  评论(0编辑  收藏  举报