带锁和钥匙的寻路?


22

我正在开发一款带有类似于锁和钥匙拼图的地图的游戏。AI需要导航到可能在红色门锁之后的目标,但红色钥匙可能在蓝色门锁之后,依此类推...

这个难题类似于塞尔达风格的地牢,如下图:

塞尔达传说地下城

要达到目标,您必须击败Boss,这需要经过坑,这需要收集羽毛,这需要收集钥匙

塞尔达传说地下城是线性的。但是,我需要解决一般情况下的问题。所以:

  • 目标可能需要一组键中的一个。因此,也许您需要获取红色键或蓝色键。否则可能会有很长一段未锁的门!
  • 可能有多种门和钥匙。例如,地图中可能有多个红色钥匙,而收集一个红色钥匙将授予所有红色门的访问权限。
  • 由于正确的钥匙在锁着的门后面,因此可能无法达到目标

如何在这样的地图上执行寻路?搜索图是什么样的?

注意:关于检测无法达到的目标的最后一点很重要;例如,如果无法达到目标,则A *效率极低。我想有效地处理这个问题。

假设AI知道地图上所有位置。


4
AI解锁后是否仅知道并发现事物?例如,它知道羽毛在锁着的门后面吗?AI是否理解诸如“那是一把锁,所以我需要一把钥匙”之类的概念,或者更简单地诸如,“我有东西挡住我的路,所以尝试一下我发现的所有东西。门上有钥匙吗?
蒂姆·霍尔特

1
之前在此问题中曾对此问题进行过一些讨论,有关前进和后退的路径查找,这可能对您有用。
DMGregory

1
因此,您不是要模拟一个玩家,而是要创建一个优化的地下城运行?我的回答肯定是关于模拟玩家的行为。
Tim Holt

4
不幸的是,很难检测到目标。确保无法达到目标的唯一方法是探索整个可到达空间,以确保其中没有一个目标-这正是A *所做的事情,如果目标是无法进入。任何搜索空间较少的算法都可能会丢失通往目标的可用路径,因为该路径隐藏在跳过搜索的部分空​​间中。您可以通过在更高级别上进行工作,而不是每个图块或导航网格多边形搜索房间连接图来加速此过程。
DMGregory

1
题外话,我本能地想到了Chip的挑战,而不是Zelda :)
扁平化,2015年

Answers:


22

标准寻路就足够了 -您的状态是您的当前位置+您的当前库存。“搬家”是指改变房间或改变存货。该答案未涵盖,但没有付出太多额外的努力,正在为A *编写一个很好的启发法-通过优先选择捡拾东西而不是移开它,宁愿解锁目标附近的门,它确实可以加快搜索速度搜寻很长的路要走等等

自从问世以来,这个答案已经得到了很多好评,并且有演示,但是对于更优化,更专业的解决方案,您还应该阅读“向后做起来更快”的答案 。/gamedev/ / a / 150155/2624


完整的Javascript概念验证如下。很抱歉将答案作为代码转储-在我确信这是一个不错的答案之前,我实际上已经实现了它,但是对我来说似乎很灵活。

要开始考虑寻路时,请记住,简单寻路算法的层次结构为:

  • 广度优先搜索功能尽可能简单。
  • Djikstra的算法类似于广度优先搜索,但是状态之间的“距离”不同
  • A *是Djikstras,在这里您可以将“正确方向的一般感觉”用作启发式方法。

在我们的案例中,只需将“状态”编码为“位置+库存”,将“距离”编码为“运动或物品使用情况”,就可以使用Djikstra或A *解决问题。

这是一些实际的代码,说明您的示例级别。第一个代码段仅供比较-如果要查看最终的解决方案,请跳至第二部分。我们从Djikstra的实现开始,它找到正确的路径,但是我们忽略了所有的障碍和关键。(尝试一下,从房间0-> 2-> 3-> 4-> 6-> 5,您可以看到它只是完成工作的直线)

function Transition(cost, state) { this.cost = cost, this.state = state; }
// given a current room, return a room of next rooms we can go to. it costs 
// 1 action to move to another room.
function next(n) {
    var moves = []
    // simulate moving to a room
    var move = room => new Transition(1, room)
    if (n == 0) moves.push(move(2))
    else if ( n == 1) moves.push(move(2))
    else if ( n == 2) moves.push(move(0), move(1), move(3))
    else if ( n == 3) moves.push(move(2), move(4), move(6))
    else if ( n == 4) moves.push(move(3))
    else if ( n == 5) moves.push(move(6))
    else if ( n == 6) moves.push(move(5), move(3))
    return moves
}

// Standard Djikstra's algorithm. keep a list of visited and unvisited nodes
// and iteratively find the "cheapest" next node to visit.
function calc_Djikstra(cost, goal, history, nextStates, visited) {

    if (!nextStates.length) return ['did not find goal', history]

    var action = nextStates.pop()
    cost += action.cost
    var cur = action.state

    if (cur == goal) return ['found!', history.concat([cur])]
    if (history.length > 15) return ['we got lost', history]

    var notVisited = (visit) => {
        return visited.filter(v => JSON.stringify(v) == JSON.stringify(visit.state)).length === 0;
    };
    nextStates = nextStates.concat(next(cur).filter(notVisited))
    nextStates.sort()

    visited.push(cur)
    return calc_Djikstra(cost, goal, history.concat([cur]), nextStates, visited)
}

console.log(calc_Djikstra(0, 5, [], [new Transition(0, 0)], []))

那么,我们如何在该代码中添加项目和键?简单!而不是每个“状态”都只以房间号开头,而是房间和我们的库存状态的元组:

 // Now, each state is a [room, haskey, hasfeather, killedboss] tuple
function State(room, k, f, b) { this.room = room; this.k = k; this.f = f; this.b = b }

现在,转换已从(成本,房间)元组变为(成本,状态)元组,因此可以同时编码“移至另一个房间”和“拾取项目”

// move(3) keeps inventory but sets the room to 3
var move = room => new Transition(1, new State(room, cur.k, cur.f, cur.b))
// pickup("k") keeps room number but increments the key count
var pickup = (cost, item) => {
    var n = Object.assign({}, cur)
    n[item]++;
    return new Transition(cost, new State(cur.room, n.k, n.f, n.b));
};

最后,我们对Djikstra函数进行一些与类型相关的较小更改(例如,它仍然只是匹配目标房间号而不是完整状态),我们得到了完整答案!请注意打印结果首先进入4号房间拿起钥匙,然后进入1号房间拿起羽毛,然后进入6号房间,杀死老板,然后进入5号房间)

// Now, each state is a [room, haskey, hasfeather, killedboss] tuple
function State(room, k, f, b) { this.room = room; this.k = k; this.f = f; this.b = b }
function Transition(cost, state, msg) { this.cost = cost, this.state = state; this.msg = msg; }

function next(cur) {
var moves = []
// simulate moving to a room
var n = cur.room
var move = room => new Transition(1, new State(room, cur.k, cur.f, cur.b), "move to " + room)
var pickup = (cost, item) => {
	var n = Object.assign({}, cur)
	n[item]++;
	return new Transition(cost, new State(cur.room, n.k, n.f, n.b), {
		"k": "pick up key",
		"f": "pick up feather",
		"b": "SLAY BOSS!!!!"}[item]);
};

if (n == 0) moves.push(move(2))
else if ( n == 1) { }
else if ( n == 2) moves.push(move(0), move(3))
else if ( n == 3) moves.push(move(2), move(4))
else if ( n == 4) moves.push(move(3))
else if ( n == 5) { }
else if ( n == 6) { }

// if we have a key, then we can move between rooms 1 and 2
if (cur.k && n == 1) moves.push(move(2));
if (cur.k && n == 2) moves.push(move(1));

// if we have a feather, then we can move between rooms 3 and 6
if (cur.f && n == 3) moves.push(move(6));
if (cur.f && n == 6) moves.push(move(3));

// if killed the boss, then we can move between rooms 5 and 6
if (cur.b && n == 5) moves.push(move(6));
if (cur.b && n == 6) moves.push(move(5));

if (n == 4 && !cur.k) moves.push(pickup(0, 'k'))
if (n == 1 && !cur.f) moves.push(pickup(0, 'f'))
if (n == 6 && !cur.b) moves.push(pickup(100, 'b'))	
return moves
}

var notVisited = (visitedList) => (visit) => {
return visitedList.filter(v => JSON.stringify(v) == JSON.stringify(visit.state)).length === 0;
};

// Standard Djikstra's algorithm. keep a list of visited and unvisited nodes
// and iteratively find the "cheapest" next node to visit.
function calc_Djikstra(cost, goal, history, nextStates, visited) {

if (!nextStates.length) return ['No path exists', history]

var action = nextStates.pop()
cost += action.cost
var cur = action.state

if (cur.room == goal) return history.concat([action.msg])
if (history.length > 15) return ['we got lost', history]

nextStates = nextStates.concat(next(cur).filter(notVisited(visited)))
nextStates.sort()

visited.push(cur)
return calc_Djikstra(cost, goal, history.concat([action.msg]), nextStates, visited)
o}

console.log(calc_Djikstra(0, 5, [], [new Transition(0, new State(0, 0, 0, 0), 'start')], []))

从理论上讲,即使在BFS上也可以使用,并且我们不需要Djikstra的成本函数,但是有了成本,我们可以说“捡起钥匙很轻松,但是与老板打架真的很困难,我们宁愿回溯如果我们可以选择的话,则可以100步而不是与老板搏斗”:

if (n == 4 && !cur.k) moves.push(pickup(0, 'k'))
if (n == 1 && !cur.f) moves.push(pickup(0, 'f'))
if (n == 6 && !cur.b) moves.push(pickup(100, 'b'))

是的,在搜索图中包括清单/关键状态是一种解决方案。不过,我担心空间的增加-具有4个键的地图需要的空间是无键图的16倍。
congusbongus

8
@congusbongus欢迎来到NP完整的旅行推销员问题。没有通用的解决方案可以在多项式时间内解决该问题。
棘轮怪胎

1
@congusbongus我一般认为您的搜索图不会有那么大的开销,但是如果您担心空间,只需打包数据即可—您可以使用24位表示房间指示器(1600万个房间足以供任何人使用),并为每个您感兴趣的用作门的项目(最多8个唯一的项目)各一个。如果您想花哨的话,可以使用依赖项将项目打包成更小的位,例如,由于存在间接传递性依赖,因此对“ key”和“ boss”使用相同的位
Jimmy,

@Jimmy即使它不是私人的,我也很高兴提及我的答案:)
Jibb Smart的

13

向后A *可以解决问题

如本问题中有关向前和向后寻路的答案中所述,向后寻路是此问题的相对简单的解决方案。这与GOAP(面向目标的行动计划)非常相似,可以规划有效的解决方案,同时最大程度地减少无目的的疑惑。

在此答案的底部,我细分了它如何处理您给出的示例。

详细

从目的地到起点的寻路。如果在寻路过程中遇到一扇锁着的门,那么您的寻路会有一个新分支,该分支将继续穿过该门,就好像门已解锁一样,而主分支将继续寻找另一条路径。像是被解锁一样继续穿过门的分支不再寻找AI代理-现在正在寻找可用于穿过门的钥匙。使用A *,其新的启发式方法是到密钥的距离+到AI代理的距离,而不仅仅是到AI代理的距离。

如果解锁门分支找到了密钥,那么它将继续寻找AI代理。

当有多个可用键时,此解决方案将变得更加复杂,但是您可以相应地进行分支。由于分支具有固定的目的地,因此仍然可以让您使用启发式方法优化寻路(A *),并希望将不可能的路径迅速切断-如果锁门周围没有办法,则分支不会穿过门不会很快用完所有选项,而穿过门并寻找钥匙的分支将继续自己运行。

当然,如果有许多可行的选择(多把钥匙,其他可以绕开门的物品,绕门的长途路线),则会维护许多分支机构,从而影响性能。但是,您还将找到最快的选项,并能够使用它。


行动中

在您的特定示例中,从目标到起点的寻路:

  1. 我们很快遇到了老板门。分支A继续穿过门,现在正在寻找要战斗的老板。分支机构B停留在房间中,一旦发现没有出路,它将很快过期。

  2. 分支A找到老板,现在正在寻找起点,但遇到一个坑。

  3. 分支A继续在坑上,但是现在它正在寻找羽毛,并且将相应地向羽毛打一条蜂线。创建了分支C,该分支试图在坑周围寻找一条路,但是一旦无法分支就过期。如果您的A *启发式方法发现分支A看起来仍然最有前途,那还是会暂时忽略它。

  4. 分支A遇到上锁的门,并继续通过上锁的门,就好像它已被解锁一样,但是现在它正在寻找钥匙。分支D也继续穿过锁着的门,仍在寻找羽毛,但随后它将寻找钥匙。这是因为我们不知道是否需要先找到钥匙或羽毛,就寻路而言,起点可能在这扇门的另一侧。分支E尝试在锁定的门周围寻找方法,但失败了。

  5. 分支D迅速找到羽毛并继续寻找钥匙。由于它仍在寻找钥匙(并且正在向后退,因此可以再次通过锁定的门)。但是一旦有了钥匙,它就无法穿过上锁的门(因为在找到钥匙之前,它无法穿过上锁的门)。

  6. 分支A和D继续竞争,但是当分支A到达密钥时,它正在寻找羽毛,并且它将无法到达羽毛,因为它必须再次穿过锁定的门。另一方面,分支D到达密钥后,将注意力转移到Start上,并发现它没有复杂性。

  7. D部门获胜。它找到了相反的路径。最终路径是:开始->键->羽毛->老板->目标。


6

编辑:这是从AI的角度编写的,它是为了探索和发现目标而事先不知道钥匙,锁或目的地的位置。

首先,假设AI具有某种总体目标。例如,在您的示例中为“查找老板”。是的,您想击败它,但实际上是要找到它。假设它存在就不知道如何达到目标。当它找到它时,它将知道它。一旦达到目标,AI即可停止工作以解决问题。

另外,即使可能是鸿沟,也要在这里使用通用术语“锁”和“钥匙”。即,羽毛“解锁”鸿沟“锁定”。

解决方法

似乎您首先应该从基本上是迷宫浏览器的AI开始(如果您将地图视为迷宫)。探索和规划它可以走到的所有地方将是AI的主要重点。它可能纯粹基于简单的内容,例如“始终走到我所看到但尚未访问过的最近路径”。

但是,在探索可能会改变优先级的规则时,会引入一些规则。

  • 除非已拥有相同的密钥,否则它将使用找到的任何密钥
  • 如果找到了以前从未见过的锁,它将尝试在该锁上找到的每个键
  • 如果钥匙在新型锁上起作用,它将记住钥匙类型和锁类型
  • 如果它找到了以前见过的并拥有钥匙的锁,它将使用记住的钥匙类型(例如,找到第二个红色锁,红色钥匙之前在红色锁上起作用,因此只需使用红色钥匙)
  • 它会记住它无法解锁的任何锁的位置
  • 它不需要记住它解锁的锁的位置
  • 每当它找到钥匙并知道以前有任何可解锁的锁时,它将立即访问这些锁中的每一个,并尝试使用新找到的钥匙将其解锁
  • 解锁路径后,只要回到探索和制图目标,就优先进入新区域

关于最后一点的说明。如果必须在检出之前看到(但未访问)的未开发区域与新解锁路径后面的未开发区域之间进行选择,则应将新解锁路径作为优先级。那可能是有用的新键(或锁)的地方。假设锁定的路径可能不会毫无意义。

使用“可锁定”键扩展想法

您可能拥有没有其他钥匙就无法取用的钥匙。或按原样锁定键。如果您知道您的旧巨洞,则需要带鸟笼来抓鸟-稍后需要用它来养蛇。因此,您可以用笼子“解锁”鸟(它不会阻挡路径,但是没有笼子就无法捡起),然后用鸟“解锁”蛇(可以阻挡您的路径)。

所以添加一些规则...

  • 如果无法获取密钥(已锁定),请尝试使用已存在的每个密钥
  • 如果找到无法解锁的钥匙,请记住以备后用
  • 如果找到新密钥,请在每个已知的锁定密钥以及锁定路径上尝试

我什至不了解有关如何携带某个钥匙可能会抵消另一把钥匙的影响的全部内容(巨大的洞穴,鱼竿吓到了鸟,必须在将鸟捡起之前将其放下,但稍后需要用它来建立魔法桥) 。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.