使用遗传算法计算最终幻想战略版(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]街道
结语
因为我还没玩,错了不要怪我
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!