当前位置: 首页 > news >正文

从零构建回合制游戏AI:基于规则与启发式评估的实战解析

1. 项目概述:一个“天真”游戏AI的诞生记

几个月前,我和另外两位工程师一起,用纯JavaScript捣鼓出了一个回合制策略游戏,我们管它叫Hexology。你可以把它想象成《Risk》(强手棋)和《卡坦岛》的混合体:一个六边形网格地图,三种作战单位,三种资源,目标就是干掉对手的所有单位。说实话,写游戏本身乐趣无穷,但我接手这个项目的首要目的,倒不是真指望做出个多么惊世骇俗、让人欲罢不能的游戏,而是想借这个机会,尽可能多地接触和学习一些新技术。我们折腾了socket.io来实现实时对战和聊天,深入研究六边形网格的几何学和SVG渲染来画棋盘,花了大量时间设计一个最终没完全用上的服务器端缓存系统,最后还把整个项目用Docker打包,扔到了亚马逊AWS的EC2上,算是体验了一把时下流行的“微服务”部署。不过,对我个人而言,整个项目最让我着迷的部分,还是设计游戏的核心逻辑。

在所有的游戏逻辑里,有三块是我直接负责并且颇为自豪的:一是我们那套有点复杂的战斗算法;二是用来生成动态地图的(虽然超级简单)程序化生成算法;三就是今天要聊的主角——Hexbot,一个你可以在单人模式里对战的、相当初级的AI。这篇文章,就是关于它的故事。这就是Hexbot,它想把你碾碎。

2. 思路抉择:为什么是“天真”的AI?

首先得声明一下:这篇文章记录的是一个“天真”的解决方案,用来应对游戏AI这个无限复杂的挑战,以及我如何在时间压力下让它跑起来。它可能不是最优解,我也欢迎任何反馈,但这确实是我的第一次尝试,希望其中的思路对你有启发。如果你正琢磨着给自己的游戏实现一个AI,或者只是好奇游戏里的机器人玩家到底在做哪些决策,那不妨继续读下去。

说实话,刚开始设计Hexbot时,我有点无从下手。前期调研大部分都围绕着机器学习和TensorFlow打转,但我很快意识到,对于一个完全的新手和一个只有一个月期限的项目来说,这完全不现实——就算我能学会平台,也没有时间和资源去训练这个AI。我也花了不少时间阅读关于有限状态机、路径寻找、A*搜索算法的资料,但出于各种原因(超出了本文范围),它们感觉都不太对劲。

最后,我偶然发现了极小化极大算法及其Alpha-Beta剪枝优化,就是当年“深蓝”用来击败世界象棋冠军的那个策略。简单来说,这个策略会尽可能向前看很多步,为每一种可能的棋盘状态生成一棵决策树,然后利用一个评估函数(启发函数)来给每一步棋打分——对AI有利的走法给正分,对对手有利的给负分。接着,它运用一点博弈论,选择能带来最佳后续棋盘状态的走法,并剪掉那些会导致次优结果的分支以节省时间。

大概有一周时间,我都坚信极小化极大算法是解决我问题的正确途径。确实,它的实现原理看起来是我能理解的。本质上,它很像人类下棋的方式,只是能获取更完美、更长远的信息。然而,当我真正坐下来尝试实现它时,很快就发现了两个致命问题。

第一,我其实并不想让Hexbot太“聪明”。我希望它首先是“有趣”和“有参与感”的对手。要让玩家有游戏体验,他们必须得有赢的机会。被“深蓝”碾压,或者在任何一个你从头到尾都没有竞争力的游戏中失败,都不是什么愉快的体验。好的游戏AI需要存在于一个微妙的中间地带:是的,它很聪明,会做一些巧妙的操作,但同时也不能太完美,要能有机地催生出有趣的游戏局面。Hexbot是否成功做到了这点见仁见智,但这确实是我考虑的一个重点,也是我决定放弃极小化极大的原因之一,我担心它会无意中造出一个过于完美的玩家,反而让游戏变得无趣。

第二,当我试图将伪代码翻译成实际的JavaScript时,我迎面撞上了一个我之前基本上一笔带过的核心概念的复杂性:那就是开发一个启发函数,将一个充满变数的棋盘状态,程序化地简化为一个单一数值的艰巨挑战。我的游戏变量实在太多了——除了机器人每个六边形格子的六种可能移动方向,它还需要决定:移动多少单位;是否要去争夺资源格;是否要购买额外单位;如果要购买,在哪里部署、部署多少;以及是否要发起战斗,如果战斗,投入多少单位。我的游戏远没有《星际争霸》那么复杂,但一个关于《星际争霸》AI的精彩视频让我深刻意识到,开发一个称职的启发函数需要投入多少,以及这种方式的局限性有多大。

在一个理想世界里,经过调整的极小化极大实现可能仍然是解决这个问题的方法。但以我手头的时间,我需要找到更“ streamlined”(精简高效)的方案。所以,我决定转向。

在研究过程中,我偶然看到了2014年惊艳之作《中土世界:暗影魔多》开发者在GDC上的一个演讲视频,他们讨论了一种叫做“目标导向行动规划”(GOAP)的策略。极度简化来说,这种策略的核心是将AI简化为一套依赖于世界状态的动作集合,这些动作用于决定达成特定目标的最佳路径。

与简单的有限状态机(FSM)——仅仅是一组由整体世界状态变化触发的、互连的离散AI状态——不同,使用GOAP的AI会制定一个目标,审视手头可用的工具,然后决定使用哪些工具、以何种顺序来最好地达成目标,并且可以实时调整策略。

GOAP对我的需求来说仍然显得有点复杂,毕竟Hexbot真正能做的事情就那么几件(购买、移动、部署、攻击)。但它确实帮我理清了一个最终导向Hexbot实现方式的思路:那就是将每一个可能的行动分解到其原子元素。这样一来,你得到的其实就是一组可组合的“子动作”,可以轻松地为它们分配任意的分值,并在需要时执行以达到期望的结果。

如果你再将这些子动作与“消灭敌方单位”这个终极目标关联起来,并时刻感知游戏的当前状态,然后简单地评估哪一系列子动作能产生最佳数值结果,你就能得到一个被大幅简化、但仍然有效且行为可预测的AI。作为一个额外的好处,你还可以通过简单地调整它用来生成数值的启发函数来改变它的行为优先级。

好了!理论部分说得够多了。所有这些在实践中到底长什么样?大部分看起来就是一大堆JavaScript代码。谁能想到呢?

3. 代码实现:Hexbot的骨架与数据收集

如果你有兴趣了解整个游戏是如何组合在一起的,我鼓励你去看看代码仓库。但我们这里只聚焦在让我们的机器人“嘀嗒”作响的核心部分。

首先,Hexbot本质上只是一个函数,它依赖几个工具函数。如果你选择与它对战,在每个玩家回合结束时执行的函数会在适当的时候调用这个AI。到目前为止,这还不算火箭科学。

每次玩家结束回合,我们调用nextTurn函数,它切换当前玩家,进行所有必要的内务处理(比如为玩家补充资源),然后,如果Hexbot在游戏中,就调用它。

我们的Hexbot文件就是所有魔法发生的地方。它有点长,近500行代码,外加100行工具函数,但理解数据流其实并不太难。事实上,其中绝大部分代码只是在收集我们所有的数据,并为接下来两个回合构建“推演”。

最终,我们要做的就是找到机器人当前所有可能的移动,以及每一次移动可能开启的所有后续移动,然后使用一个启发函数来评估它们,该函数会给出一个数值,并决定机器人将采取什么行动。小菜一碟。

3.1 基础数据与变量准备

文件开头,我们定义了一个常量,用来表示所有六边形格子之间的相邻关系。在六边形网格上理解坐标和空间几何比标准的笛卡尔坐标系更具挑战性(如果你想要更多背景知识,我强烈推荐Red Blob Games的那篇经典文章)。尽管我们项目的延伸目标之一是支持可变尺寸的地图,但我们最终没完全实现。所以目前,我们只是简单地硬编码了一个对象,列出了所有17个格子和它们的邻居。看起来就像这样:

const HEX_RELATIONSHIPS = { 0: [1, 2, 3, 4, 5, 6], 1: [0, 2, 6, 7, 8, 18], // ... 其他格子的邻居关系 16: [10, 11, 12, 15, 17] };

接下来是hexbot函数本身(也就是文件的其余部分)。我们首先要获取/创建在整个过程中需要的所有变量。其中一些只与内务处理/传递应用程序状态有关,机器人需要访问这些状态,但它们都有助于我们理解我们实际上要做什么:

function hexbot(socket, room, boardState) { // 内务处理相关 const currentPlayer = 2; // 假设AI总是玩家2 const unitTotals = calculateUnitTotals(boardState, currentPlayer); const resources = getPlayerResources(boardState, currentPlayer); const unitBank = getUnitBank(boardState, currentPlayer); // 核心分析数据结构 const possibleMoves = {}; // 存储当前回合所有可能的移动 const possibleNextTurnMoves = {}; // 存储下一回合所有可能的后续移动 const moveValues = {}; // 存储当前回合每个可能移动的启发式分值 // 决策变量 let bestMove = { from: null, to: null, value: -Infinity }; let worstSecondaryThreat = { value: -Infinity, purchaseNeeded: null }; let purchase = null; // 记录本轮最佳移动是否需要伴随购买 }

socketroom属于内务处理。boardState是一个对象数组,代表了任何时刻棋盘上的实际情况——我们将把它作为分析的起点。单位总数、资源、单位库等变量同样提取了关键的应用状态。

但从第35行左右开始,才是最有意思的部分,我们可以开始理解整体策略了:我们有三个对象,分别用来存储 1) 机器人当前回合可以做的所有可能移动(possibleMoves),2) 机器人下一回合可以做的所有可能移动(possibleNextTurnMoves),以及 3) 当前回合每个可用移动的启发式分值(moveValues)。

你可能觉得奇怪,为什么没有possibleNextTurnMovesValues对象?因为我们不会直接单独评估它们,毕竟它们不影响机器人本轮要做什么。相反,对于本轮我们考虑的每一个移动,我们都会查看它所有可能引发的后续移动,并简单地用它们来修正本轮移动的分值。这就像人类玩家下棋时的思考:你只能移动这一步,但你总会考虑这一步棋会打开怎样的可能性空间,而这个可能性空间会影响你这一步决策的权重。

另外三个对我们的流程真正重要的变量是bestMoveworstSecondaryThreatpurchase。其中purchase最无趣:它只是一个存储值,用来记录作为当前最佳移动的一部分,是否需要(以及需要)进行何种购买!bestMove本身则是一个运行记录,记录当前回合的最佳移动,用其坐标和我们通过启发式生成的数值来表示。我们最终将使用这个数组来实际执行机器人的移动。worstSecondaryThreat类似,但我们用它来判断机器人是否需要购买单位来应对下一回合可能成为问题的敌方威胁——我们不需要worstThreat变量的原因是,即时威胁已经作为可能的移动被直接评估过了。

注意bestMoveworstSecondaryThreat都用负无穷大(-Infinity)初始化:除了立即导致游戏失败的状态,任何移动,即使得分为负,也比原地不动要好。

3.2 遍历棋盘,收集信息

接下来,我们进入循环阶段。很多很多的循环。第一个循环的目标很简单:遍历整个棋盘,收集其布局信息。我们只对机器人当前拥有单位的每个六边形格子,以及它的邻居和“邻居的邻居”(二级邻居)感兴趣。所以,我们只调查那些被玩家二(也就是我们的机器人)占据的格子。为了方便,我们会把这些格子的索引存储在一个对象里。注意,我们存储的不是整个格子对象,只是它的索引引用,因为必要时我们总是可以回头查看棋盘状态来了解格子上有什么。

const botHexes = []; for (let i = 0; i < boardState.length; i++) { if (boardState[i].owner === currentPlayer) { botHexes.push(i); // 存储机器人控制的格子索引 // 初始化数据结构 possibleMoves[i] = {}; possibleNextTurnMoves[i] = {}; moveValues[i] = {}; } }

循环一完成!

接下来,每当我们找到一个机器人控制的格子,我们想遍历它的每一个邻居,判断是否被敌人占据,或者是否有资源(以及是什么资源)。我们要把这些格子存储到“即时威胁”列表和“资源格”列表中——关键点在于:要保留这种相对关系。因为如果棋盘上有多个机器人控制的格子,我们需要知道是哪一个格子受到了特定敌方部队的威胁,或者紧挨着某个资源,而不是笼统地知道棋盘上某个地方有个值得关注的邻居。也就是说,如果AI控制了6号和17号格子,而5号格子上有敌人,我们需要知道5号格子是对6号格子的威胁,而不仅仅是一个笼统的威胁。我们会在其他地方也应用这种模式。

const threats = {}; // 格式:{ botHexIndex: [enemyNeighborIndex1, ...] } const resourcesNearby = {}; // 格式:{ botHexIndex: [{neighborIndex, resourceType}, ...] } for (const botHex of botHexes) { threats[botHex] = []; resourcesNearby[botHex] = []; const neighbors = HEX_RELATIONSHIPS[botHex]; for (const neighbor of neighbors) { const tile = boardState[neighbor]; if (tile.owner && tile.owner !== currentPlayer) { threats[botHex].push(neighbor); } if (tile.resourceType) { resourcesNearby[botHex].push({ index: neighbor, type: tile.resourceType }); } } }

循环二完成!

现在我们已经考虑了所有机器人格子和它们的直接邻居,我们还想考虑每个邻居格子的邻居。这个过程和上面很相似;唯一的区别是,我们将把可能性收集在possibleNextTurnMoves对象中,并且所有的存储都比之前嵌套深一层。一个给定格子的最多六个邻居格子,每个都有六个潜在的邻居,我们希望将所有这些潜在的邻居都存储为原始邻居可能性空间的一部分……如果你现在有点头晕,没关系,语法是有点绕,但核心概念——每一个二级移动都是某个给定移动的结果,我们希望保留整个链条(当前格子 -> 它的直接邻居 -> 那些邻居的邻居)——才是重要的。

for (const botHex of botHexes) { const neighbors = HEX_RELATIONSHIPS[botHex]; for (const neighbor of neighbors) { // 初始化二级移动存储结构 if (!possibleNextTurnMoves[botHex][neighbor]) { possibleNextTurnMoves[botHex][neighbor] = []; } const secondaryNeighbors = HEX_RELATIONSHIPS[neighbor]; for (const secNeighbor of secondaryNeighbors) { // 排除掉当前机器人控制的格子本身(不能走回头路?视规则而定) if (secNeighbor !== botHex) { possibleNextTurnMoves[botHex][neighbor].push(secNeighbor); } } } }

循环三完成!啊!

4. 核心分析:模拟、评估与决策

好了!对于记分的人来说,我们已经收集了所有邻居和二级邻居的关系,包括资源和敌人的位置。所有这些信息都准备好被分析了!是时候——你猜对了——写更多的循环来遍历我们所有的选项并开始计算了。

这是从三万英尺高空俯瞰的总体计划:

  1. 模拟战斗:使用当前单位数量,运行所有潜在战斗的模拟,包括本轮和下一轮的。
  2. 处理移动:返回模拟结果,并将其存储为可能的移动选项。
  3. 查漏补缺:对于任何当前模拟结果是平局或失败的战斗,检查机器人是否可以在当前或未来进行购买,以将失败转为平局甚至胜利。更新这些移动的现有条目,存储新的结果和必要的购买信息。
  4. 启发式评估:对每一个可能的移动,执行我们的启发式操作,得到一个数值分数,并存储到moveValues对象中。这个启发式将同时考虑移动本身的价值和它的后续移动(即选择该移动后,下一回合可能开启的移动)的价值。
  5. 做出决策:一旦处理完所有可能的移动,就执行最好的那一个!

听起来还不赖!我会省去战斗模拟算法的细节(第1和第2点),因为它们充满了变量,并且还需要解释战斗系统。让我们直接看看那个在实际获得战斗模拟值后、遍历所有可能移动并运行启发式函数的函数。它其实非常简单:

function evaluateAllMoves(possibleMoves, possibleNextTurnMoves, moveValues) { for (const fromHex in possibleMoves) { for (const toHex in possibleMoves[fromHex]) { const moveInfo = possibleMoves[fromHex][toHex]; // 包含模拟结果等 let score = heuristic(moveInfo, false); // 评估主移动,false表示不是二级移动 // 如果有后续移动,评估它们并加权加到总分 if (possibleNextTurnMoves[fromHex] && possibleNextTurnMoves[fromHex][toHex]) { const secondaryMoves = possibleNextTurnMoves[fromHex][toHex]; let secondaryScore = 0; for (const secMove of secondaryMoves) { // 这里需要根据secMove构造一个类似moveInfo的对象,通常基于推演状态 // 假设有一个函数能根据当前状态和移动目标,生成一个推演后的“未来移动信息” const futureMoveInfo = simulateFutureMove(moveInfo, secMove); secondaryScore += heuristic(futureMoveInfo, true); // true表示是二级移动 } // 二级移动的分数通常要打折扣,因为未来不确定 score += secondaryScore * 0.5; // 例如,权重因子设为0.5 } moveValues[fromHex][toHex] = score; // 存储最终分数 } } }

提醒一下,moveValues将是一个对象,其键代表所有当前由机器人控制的格子,值则是嵌套对象,包含从该格子出发的所有可能移动及其相关的数值分数。一个机器人单位在17号格子,可以移动到13号和16号格子,分数分别为25和50的示例对象如下:

{ 17: { 13: 25, 16: 50 } }

重申一下,这个函数只是遍历possibleMoves对象中的每个格子,对于从该格子出发的每一个可能移动,对其及其二级后续移动运行我们的启发式评估,最后将计算出的最终分数放入moveValues对象进行跟踪。我们快完成了!

4.1 启发函数:AI的“价值观”

在我们看如何选择最佳移动之前(一旦我们有了所有分值,这其实很简单),让我们先暂停一下,看看启发函数本身,因为理解底层发生了什么很有价值。我基本上是选取了一些任意值,你可以根据需求调整它们来创造不同的游戏平衡。立即胜利值无穷大,立即失败值负无穷大,赢得一场战斗(但未赢得游戏)值75分,获得一个资源或打成平局值15分,等等。

我们也会递归地调用这个启发函数在与当前移动相关的每一个后续移动上,将这些分值稍微降低权重后,计入当前移动的最终得分——这就是函数中secondary标志变量的作用,它记录我们当前查看的是主移动还是二级移动。例如,即使作为当前移动结果的立即胜利值无穷大,但一个可能最终导致游戏胜利的移动可能只值100分,因为一个回合内可能发生很多变化。我们在计算这些值时,考虑了资源邻近度、战斗胜败和游戏输赢状态。

二级移动评估函数本质上只是对每个二级移动调用启发函数,并将返回的分数加到主移动的总分上。如果你还记得,二级移动实际上只是对当前可能移动的修正,所以它们没有自己的独立分数,只是影响主移动的分数。

既然我们已经为每一个潜在移动和二级移动计算了假设的战斗结果,我们要做的就是将这些结果通过这个启发函数运行,得到我们的点值,然后瞧!分数到手,我们差不多完成了!

4.2 最终决策与执行

好了,最后冲刺。让我们最终用上文章开头提到的bestMove变量。下面是一个小循环来检查我们所有可用的选项并找到最优移动:

for (const fromHex in moveValues) { for (const toHex in moveValues[fromHex]) { const currentScore = moveValues[fromHex][toHex]; if (currentScore > bestMove.value) { bestMove.value = currentScore; bestMove.from = parseInt(fromHex); // 确保是数字 bestMove.to = parseInt(toHex); // 同时,从possibleMoves中获取这个移动关联的购买信息(如果有) purchase = possibleMoves[fromHex][toHex].purchase || null; } } }

如你所见,我们需要做的就是检查收集到的moveValues对象中每一项的启发值,并在遍历过程中跟踪最高分的移动(起始格索引、目标格索引和启发值)。不能再简单了!

一旦循环运行完毕,剩下的就是在实际执行移动之前,先进行单位的购买(如果需要的话)。这里还有一个小问题:尽管我们一直在跟踪下一回合的战斗,并判断了本轮购买是否会对它们产生影响,但我们除了用它作为本轮移动的分值修正外,还没有用这些信息任何事情。我们不希望Hexbot盲目地走向一场它不买单位就无法赢得的战斗,而这些单位它本来是买得起的。因此,我们有最后一个检查,worstSecondaryThreatCheck,它判断:如果本轮移动本身不包含固有的购买部分,机器人应该检查对手的反应是否会迫使它必须购买部队。换句话说:如果机器人在一个格子里有10个单位,它的对手在两个格子外有15个单位,并且它有足够资源购买10个单位,我们需要确保它确实进行了购买,即使它本轮只是向敌人靠近(这本身并不要求本轮购买)。

这里有一些条件逻辑来确保这种情况发生!

// 假设worstSecondaryThreat中存储了下一回合最严重的威胁及其需要的购买 if (!purchase && worstSecondaryThreat.purchaseNeeded) { // 检查资源是否足够进行这个“防御性”购买 if (canAfford(resources, worstSecondaryThreat.purchaseNeeded)) { purchase = worstSecondaryThreat.purchaseNeeded; // 更新棋盘状态(单位库等) executePurchase(purchase, boardState, currentPlayer); } }

这样,最佳移动已被选出,购买也已执行……最后我们只需要发出一个socket事件让服务器知晓并推动游戏进程,设置一个简单的“思考中”动画,然后我们就完成了!我们造出了一个机器人!

5. 反思、局限与未来可能

三千五百多字和超过五百行代码之后,我们有了一个可以用于单人对战的机器人。仍然有大量的功能可以添加——你会注意到我们从未给Hexbot分配军队的能力,它没有考虑对手的资源或未部署的单位,而且我们永远只向前看两个回合,等等。它还有一个坏习惯:如果没有单一的最佳选择,它就会径直向左上方移动(哎呀!¯\_(ツ)_/¯)。

然而,这个最小可行产品(MVP)是完全可用的,尽管有点可预测。事实上,我得到的反馈是,大多数人在摸清它的模式之前都觉得它有点太难了,这在挑战性上似乎是一个相当好的甜点区。

5.1 核心局限与“天真”之处

  1. 前瞻深度有限:只向前看两个回合(当前回合和下一回合),这限制了它的战略深度。它无法制定长期计划,比如牺牲一个单位来换取三个回合后的位置优势。
  2. 状态评估简单:启发函数虽然有效,但赋值相对武断和静态。它没有学习能力,不会根据对手的风格调整策略。评估仅基于即时和短期的、直接量化的收益(如单位数量差、资源获取),缺乏对“棋盘控制”、“发展潜力”等抽象概念的评估。
  3. 行动空间不完整:如文中所述,它不支持分兵(将一个格子的单位移动到多个相邻格子),这是一个重要的战术缺陷。它的行动集是原子化的,但组合方式受限。
  4. 无对手建模:它完全无视对手的决策模式、资源情况和未部署的“隐藏”力量。这是一个纯粹基于当前棋盘状态的“反应式”AI,而非“预测式”AI。
  5. 决策确定性:由于没有引入随机性,且评估函数是确定性的,在相同棋盘状态下,Hexbot总是做出相同的选择。这导致了可预测的模式,玩家一旦掌握即可轻松利用。

5.2 如果时间允许:改进方向

如果项目时间更充裕,我会从以下几个方向尝试改进Hexbot:

  1. 引入随机性:在分数相近的多个选项中随机选择,或者以一定概率选择非最优但“有趣”的选项(例如,偶尔冒险攻击一个资源丰富的格子,即使短期分数不是最高)。这能大大增加AI行为的不可预测性和趣味性。
  2. 实现分兵逻辑:这需要重构移动生成和评估逻辑。一个简单的实现是,将“从A移动X个单位到B”视为一个独立动作,并评估不同X值的结果。这能显著提升战术灵活性。
  3. 深化搜索与剪枝:尝试实现一个简化版的蒙特卡洛树搜索(MCTS),特别是在游戏后期单位数量较少时。MCTS通过随机模拟来评估行动,不需要复杂的启发函数,可能更适合这种多变量的游戏。
  4. 动态启发函数:让启发函数的权重根据游戏阶段动态调整。例如,游戏早期更看重资源收集,中期看重单位数量和压制,后期看重致命一击。甚至可以设计几个不同的“人格”配置文件(激进型、防守型、扩张型),在游戏开始时随机选择。
  5. 简单的对手推断:记录对手的行动模式(例如,是否倾向于聚集兵力、是否喜欢抢占特定资源)。虽然不实现完整的对手建模,但可以简单调整自身对“威胁”和“机会”的评估权重。

5.3 给后来者的实操建议

如果你也想为自己的策略游戏实现一个“第一版”AI,以下是我踩过坑后的心得:

  • 从最蠢的开始:不要一开始就追求MCTS或神经网络。像Hexbot这样基于规则和简单评估的AI是一个完美的起点。它能让你快速验证游戏核心循环是否有趣,并明确AI需要哪些信息来做决策。
  • 数据管道先行:花时间设计好AI获取游戏状态的数据接口。清晰的boardStategetPossibleMoves()simulateMove(move)函数,比一个复杂的算法更重要。这能让后续迭代和调试轻松百倍。
  • 可视化调试是神器:为你的AI开发一个简单的调试视图,能显示它计算出的所有可能移动及其分数。当AI做出愚蠢决策时,这个视图能帮你快速定位是数据错了,还是评估逻辑错了。
  • 量化你的“乐趣”:思考一下,对你游戏而言,什么是“好的”AI行为?是胜率保持在50%?是每局游戏时间在10-15分钟?还是能产生令人难忘的“翻盘”时刻?尝试用一些可测量的指标来定义它,并以此调整你的启发函数。
  • 拥抱“天真”:承认你的第一个AI版本肯定是幼稚的、有缺陷的。把它做出来,让人玩,收集反馈。玩家对AI的抱怨(“它总是做X”,“它从来不做Y”)是改进它最宝贵的需求清单。

最终,Hexbot作为一个“天真”的游戏AI,它完成了它的使命:让单人游戏模式得以运行,提供了一个有基本挑战性的对手,并且最重要的是,它成为了一个绝佳的学习载体。通过构建它,我深入理解了游戏状态表示、行动空间枚举、基于规则的评估以及权衡决策的复杂性——这些概念是构建更复杂AI的基石。它不完美,但它是可行的,并且为未来所有更“聪明”的版本铺平了道路。在游戏开发中,一个能跑起来的简单方案,远胜过一个停留在纸面上的完美构想。

http://www.rkmt.cn/news/1432531.html

相关文章:

  • 告别玄学重启!用FreeRTOS任务管理思维,根治ESP32-C3栈空间不足的毛病
  • 别再手动画封装了!用AD的IPC向导5分钟搞定SOP-8封装(含STEP模型生成)
  • Vivado IP核的Modelsim仿真库:一次编译,多个工程复用(附.ini文件配置详解)
  • ROS 2迁移指南:把ros::NodeHandle那点事,换成rclcpp的NodeOptions和生命周期怎么搞?
  • AI写作助手:从NLP原理到内容创作全流程实战指南
  • 规则化提示词:提升团队效能的ChatGPT工程化实践
  • 从混沌到稳态:一位CTO的自白——我是如何用Lindy函数计算自动化让核心API平均存活期延长11.3年?
  • Zotero进阶操作:Shift移动、Ctrl高亮,这些隐藏快捷键让你效率翻倍
  • AI内容创作:YouTube变现全流程实战指南与增长策略
  • 深入瑞萨RH850 HSM的‘保险箱’:安全密钥存储与Flash隔离机制全解析
  • 提示工程进阶:思维链、角色扮演与自动化工作流实战
  • ARM GIC电平触发中断处理机制详解
  • GPT-4核心技术解析:从MoE架构到工程实践应用
  • 从零移植一个ESP32开源项目:手把手教你用VSCode配置IDF_PATH和解决分区表错误
  • 告别环境配置烦恼:用Adoptium JDK 13搞定OpenTCS 5.11开发环境(附常见报错解决)
  • 别再羡慕扫描全能王了!用Python+OpenCV+scikit-image,5分钟搞定批量图片转扫描件(附完整代码)
  • VASP计算完别急着关!手把手教你从OUTCAR、CONTCAR里‘挖’出有用数据
  • 从16450到AXI UART 16550:一个经典串口IP在FPGA上的“现代化”之旅
  • HC-SR04测距不准?可能是你的STM32定时器没配好!一份超详细的精度调试指南
  • VASP计算完别急着关!手把手教你从OUTCAR、CONTCAR里“挖”出你要的数据
  • 保姆级教程:在Ubuntu 22.04上从零搭建ROS2 Humble的TurtleBot3仿真环境(含Gazebo和Navigation2)
  • 从飞机零件到汽车制动盘:聊聊SOLIDWORKS拓扑优化,如何让传统制造也玩转‘仿生设计’
  • 避坑指南:Unity InputSystem做虚拟摇杆时,多指触控与UI事件冲突怎么破?
  • 避坑指南:在UE中实现物体描边时,如何解决深度检测的闪烁与法线残留问题?
  • 新电脑开机7分钟就蓝屏?手把手教你用WinDbg揪出DRIVER_POWER_STATE_FAILURE元凶
  • 新手必看:Betaflight和PX4飞控IMU方向设置避坑指南(附常见传感器映射表)
  • 从激光切割机到3D打印机:手把手移植GRBL步进电机算法到STM32F103(附源码解析)
  • 高并发场景下,Lettuce异步与反应式编程实战:告别Jedis连接池烦恼
  • 告别烘焙!用UE5 Lumen做动态场景全局光照,这份性能与效果平衡指南请收好
  • C#上位机实战:用Halcon的HSmartWindowControl搞定ROI绘制与参数提取(附完整源码)