2

Vexed 是一款流行的益智游戏,有许多版本可用(其中一些是 GPL 自由软件)。非常适合小屏设备;版本可用于 Android、iOS 等。我在 PalmOS 平台上发现了它。

只是为了好玩,我想写一个求解器来解决 Vexed 级别。

Vexed 是一款方块滑动益智游戏。简而言之,以下是规则:

0)每个级别都是一个正方形网格,以不可通行的边界为界。在任何关卡中都会有一些实心方块,它们是无法通过的。有一些不同颜色的块;这些可以放在底部边框上,放在实心方块上,或者放在其他块(不同颜色)上。大多数级别是 8x8 或更小。

1) 你唯一能做的就是向左或向右滑动一个方块。每走一格算作一次移动。

2)有重力。如果在你滑动一个方块后,它不再停留在一个实心方块或另一个方块上,它会下落,直到它停留在另一个方块、一个实心方块或底部边框上。请注意,您永远无法再次抬起它。

3) 任何时候两个或多个相同颜色的方块接触,它们就会消失。请注意,链是可能的:如果支撑块消失,则支撑在其上的块会掉落,这可能导致更多相同颜色的块接触并因此消失。

4)目标是使所有块在最少的移动次数内消失。每个级别都有一个“标准分数”,它告诉您最少的移动次数。(在最初的 PalmOS 游戏中,“标准分数”不一定是最低的,但在我最近玩的 Android 版本中,它是最低的。)

以下是包含 PalmOS 版本游戏源代码的 SourceForge 项目:

http://sourceforge.net/projects/vexed/

我是一位经验丰富的软件开发人员,但我还没有在 AI 方面做过任何工作(寻路、解决问题等),所以我正在寻找建议,让我指出正确的方向。

目前,我可以看到我要追求的两个基本策略:

0) 只需编写一个蛮力求解器,可能是为了速度而用 C 语言,它会遍历每场比赛的所有可能解决方案,并返回所有解决方案的列表,最好的一个在前。这是一种合理的方法,还是可能的移动总数会使这太慢?我认为不存在任何大于 10x10 的关卡。

1) 学习一些 AI-ish 算法,并以聪明的方式应用它们来解决问题,可能使用 Python。

请注意,PalmOS Vexed 的源代码包括一个求解器。根据作者的说法,“求解器使用带有剪枝启发式的 A* 来寻找解决方案。”

http://www.scottlu.com/Content/Vexed.html

因此,我可以采用的一种策略是研究 A* 算法,然后研究现有求解器的 C++ 代码并尝试从中学习。

我将用 Python 和 C 标记对此进行标记,但如果您认为我应该使用其他东西,请进行销售宣传,我会考虑的!

这是来自“Variety 25 Pack”的一个级别的 ASCII 艺术;48级,“黑魔王”。我能够解决大多数关卡,但这个关卡让我很烦恼。这个级别的标准杆分数是 25 步,但我还没有解决它!

__________
|## g####|
|## # b##|
|## # p##|
|#g   ###|
|bp   ###|
|p# p g  |
==========

在这张图片中,边框是下划线、竖线和等号字符。填充方块是“#”。空位是空格字符。彩色块是“g”(绿色)、“b”(蓝色)和“p”(紫色)。

顺便说一句,我可能会将求解器的输入文件格式设置为关卡的 ASCII 艺术,就像这样,但没有繁琐的线条边框字符。

感谢您的任何建议!

编辑:

我已经接受了一个答案。感谢给我答案的人。

这是一个半蛮力求解器。它没有使用 A*,但它正在削减无利可图的短枝。

它读入一个带有关卡数据的简单文本文件。字母是块,“_”(下划线)是空格,“#”是填充空格。

#!/usr/bin/env python
#
# Solve levels from the game Vexed.


from collections import Counter
import sys


level_blocks = set(chr(x) for x in range(ord('a'), ord('z')+1))
level_other = set(['_', '#'])
level_valid = set().union(level_blocks, level_other)


def prn_err(s='\n'):
    sys.stderr.write(s)
    sys.stderr.flush()


def validate_llc(llc):
    if len(llc) == 0:
        raise ValueError, "need at least one row of level data"
    w = len(llc[0])
    if w < 2:
        raise ValueError, "level data not wide enough"
    for i, row in enumerate(llc):
        if len(row) != w:
            s = "level data: row %d is different length than row 0"
            raise ValueError, s % i
        for j, ch in enumerate(row):
            if ch not in level_valid:
                s = "char '%c' at (%d, %d) is invalid" % (ch, i, j)
                raise ValueError, s


class Info(object):
    pass

info = Info()

info.width = 0
info.height = 0
info.spaces = set()
info.boom_blocks = set()
info.best_solution = 9999999999
info.title = "unknown"



class Level(object):
    """
    Hold the state of a level at a particular move.  self.parent points
    to the previous state, from a previous move, so the solver builds a
    tree representing the moves being considered.  When you reach a solution
    (a state where there are no more blocks) you can walk up the tree
    back to the root, and you have the chain of moves that leads to that
    solution."""
    def __init__(self, x):
        if isinstance(x, Level):
            self.blocks = dict(x.blocks)
            self.colors = dict(x.colors)
            self.parent = x
            self.s_move = ''
            self.rank = x.rank + 1
        else:
            if isinstance(x, basestring):
                # allow to init from a one-line "data" string
                # example: "___;___;r_r"
                x = x.split(';')

            # build llc: list of rows, each row a list of characters
            llc = [[ch for ch in row.strip()] for row in x]
            llc.reverse()

            info.width = len(llc[0])
            info.height = len(llc)
            validate_llc(llc)

            # Use llc data to figure out the level, and build self.blocks
            # and info.spaces.  self.blocks is a dict mapping a coordinate
            # tuple to a block color; info.spaces is just a set of
            # coordinate tuples.
            self.blocks = {}
            for y in range(info.height):
                for x in range(info.width):
                    loc = (x, y)
                    c = llc[y][x]
                    if c == '_':
                        # it's a space
                        info.spaces.add(loc)
                    elif c in level_blocks:
                        # it's a block (and the block is in a space)
                        self.blocks[loc] = c
                        info.spaces.add(loc)
                    else:
                        # must be a solid square
                        assert(c == '#')

            # colors: map each block color onto a count of blocks.
            self.colors = Counter(self.blocks.values())

            # parent: points to the level instance that holds the state
            # previous to the state of this level instance.
            self.parent = None

            # s_move: a string used when printing out the moves of a solution
            self.s_move = 'initial state:'

            # rank: 0 == initial state, +1 for each move
            self.rank = 0

            self.validate()

            print "Solving:", info.title
            print
            sys.stdout.flush()

        if self._update():
            print "level wasn't stable!  after updating:\n%s\n" % str(self)

    def lone_color(self):
        return any(count == 1 for count in self.colors.values())

    def is_solved(self):
        return sum(self.colors.values()) == 0

    def validate(self):
        if info.height == 0:
            raise ValueError, "need at least one row of level data"
        if info.width < 2:
            raise ValueError, "level data not wide enough"
        if self.lone_color():
            raise ValueError, "cannot have just one of any block color"
        for x, y in info.spaces:
            if not 0 <= x < info.width or not 0 <= y < info.height:
                raise ValueError, "Bad space coordinate: " + str(loc)
        for x, y in self.blocks:
            if not 0 <= x < info.width or not 0 <= y < info.height:
                raise ValueError, "Bad block coordinate: " + str(loc)
        if any(count < 0 for count in self.colors.values()):
            raise ValueError, "cannot have negative color count!"

        colors = Counter(self.blocks.values())
        for k0 in [key for key in self.colors if self.colors[key] == 0]:
            del(self.colors[k0])  # remove all keys whose value is 0
        if colors != self.colors:
            raise ValueError, "self.colors invalid!\n" + str(self.colors)

    def _look(self, loc):
        """
return color at location 'loc', or '_' if empty, or '#' for a solid sqaure.
A bad loc does not raise an error; it just returns '#'.
        """
        if loc in self.blocks:
            return self.blocks[loc]
        elif loc in info.spaces:
            return '_'
        else:
            return '#'

    def _lookxy(self, x, y):
        loc = x, y
        return self._look(loc)

    def _board_mesg(self, mesg, loc):
        x, y = loc
        return "%s %c(%d,%d)" % (mesg, self._look(loc), x, y)

    def _blocked(self, x, y):
        return self._lookxy(x, y) != '_'

    def _s_row(self, y):
        return ''.join(self._lookxy(x, y) for x in xrange(info.width))
    def data(self, ch_join=';'):
        return ch_join.join(self._s_row(y)
                for y in xrange(info.height - 1, -1, -1))

    # make repr() actually print a representation
    def __repr__(self):
        return type(self).__name__ + "(%s)" % self.data()

    # make str() work
    def __str__(self):
        return self.data('\n')


    def _move_block(self, loc_new, loc_old):
        self.blocks[loc_new] = self.blocks[loc_old]
        del(self.blocks[loc_old])

    def _explode_block(self, loc):
        if loc in info.boom_blocks:
            return

        info.boom_blocks.add(loc)
        color = self.blocks[loc]
        self.colors[color] -= 1

    def _try_move(self, loc, d):
        x, y = loc
        if not d in ('<', '>'):
            raise ValueError, "d value '%c' invalid, must be '<' or '>'" % d

        if d == '<':
            x_m = (x - 1)
        else:
            x_m = (x + 1)
        y_m = y
        loc_m = (x_m, y_m)
        if self._blocked(x_m, y_m):
            return None # blocked, so can't move there

        # Not blocked.  Let's try the move!
        # Make a duplicate level...
        m = Level(self)
        # ...try the move, and see if anything falls or explodes...
        m._move_block(loc_m, loc)
        m._update()
        if m.lone_color():
            # Whoops, we have only one block of some color.  That means
            # no solution can be found by considering this board.
            return None

        # finish the update
        m.s_move = self._board_mesg("move:", loc) + ' ' + d
        m.parent = self
        return m

    def _falls(self, loc):
        x, y = loc
        # blocks fall if they can, and only explode when at rest.

        # gravity loop: block falls until it comes to rest
        if self._blocked(x, y - 1):
            return False # it is already at rest

        while not self._blocked(x, y - 1):
            # block is free to fall so fall one step
            y -= 1

        loc_m = (x, y)
        self._move_block(loc_m, loc)
        return True # block fell to new location

    def _explodes(self, loc):
        x, y = loc
        exploded = False

        color = self._look(loc)
        # look left, right, up, and down for blocks of same color
        for e_loc in [(x-1, y), (x+1, y), (x, y-1)]:
            if e_loc in self.blocks and self.blocks[e_loc] == color:
                self._explode_block(e_loc)
                exploded = True

        if exploded:
            self._explode_block(loc)

        return exploded

    def _update(self):
        c = 0
        while True:
            # TRICKY: sum() works on functions that return a bool!
            # As you might expect, True sums as 1 and False as 0.
            f = sum(self._falls(loc) for loc in self.blocks)
            e = sum(self._explodes(loc) for loc in self.blocks)
            for loc in info.boom_blocks:
                del(self.blocks[loc])
            info.boom_blocks.clear()
            c += f + e
            if (f + e) == 0:
                # no blocks fell or exploded; board is stable, update is done
                break
        return c

    def print_moves(self):
        lst = [self]
        a = self
        while a.parent:
            a = a.parent
            lst.append(a)
        lst.reverse()
        for i, a in enumerate(lst):
            if i:
                print "Move %d of %d" % (i, len(lst) - 1)
            print a.s_move
            print a
            print


    def solve(self):
        c = 0
        seen = set()
        solutions = []

        seen.add(self.data())
        q = []
        if self.is_solved():
            solutions.append(self)
        else:
            q.append(self)

        while q:
            a = q.pop(0)

            # Show dots while solver is 'thinking' to give a progress
            # indicator.  Dots are written to stderr so they will not be
            # captured if you redirect stdout to save the solution.
            c += 1
            if c % 100 == 0:
                prn_err('.')

            if a.rank > info.best_solution:
                # We cannot beat or even match the best solution.
                # No need to think any more about this possibility.
                # Just prune this whole branch of the solution tree!
                continue

            for loc in a.blocks:
                for d in ('<', '>'):
                    m = a._try_move(loc, d)
                    if not m or m.data() in seen:
                        continue

                    if m.is_solved():
                        if info.best_solution > a.rank:
                            print "\nnew best solution: %d moves" % m.rank
                            info.best_solution = a.rank
                        else:
                            print "\nfound another solution: %d moves" % m.rank
                        solutions.append(m)
                    else:
                        seen.add(m.data())
                        q.append(m)
        print
        print "Considered %d different board configurations." % c
        print

        solutions.sort(key=lambda a: a.rank)
        for n, a in enumerate(solutions):
            print "solution %d): %d moves" % (n, a.rank)
            a.print_moves()

        if not solutions:
            print "no solutions found!"


def load_vex_file(fname):
    with open(fname, "rt") as f:
        s = f.next().strip()
        if s != "Vexed level":
            raise ValueError, "%s: not a Vexed level file" % fname
        s = f.next().strip()
        if not s.startswith("title:"):
            raise ValueError, "%s: missing title" % fname
        info.title = s[6:].lstrip()  # remove "title:"
        for s in f:
            if s.strip() == "--":
                break
        return Level(f)



if __name__ == "__main__":
    if len(sys.argv) == 1:
        print "Usage vexed_solver <vexed_level_file.vex>"
        sys.exit(1)

    fname = sys.argv[1]
    level = load_vex_file(fname)
    level.solve()

这是一个示例级别文件:

Vexed level
title: 25-48, "Dark Lord"
--
##_f####
##_#_c##
##_#_p##
#f___###
cp___###
p#_p_f__

在我的电脑上,考虑到 14252 种不同的板配置,它几乎在 10 秒内就解决了“黑魔王”。我用 Python 2.x 而不是 Python 3 编写的,因为我想用 PyPy 试试这个,看看它有多快。

接下来我应该努力将 A* 应用于此。我想我可以制定一个指标,例如“将橙色块移向另一个橙色块而不是移开”并尝试将其计算在内。但我确实希望所有解决方案都能弹出,所以也许我已经完成了。(如果有三个解决方案都是最小移动数,我想看到所有三个。)

欢迎对这个 Python 程序发表评论。我写得很开心!

编辑:我确实用 PyPy 试过这个,但直到现在我才更新这个。在我使用 PyPy 的计算机上,求解器可以使用 CPython 在 10 秒内解决“黑魔王”关卡;PyPy 下降到 4 秒。很酷的部分是我可以看到JIT 启动时的加速:这个程序在工作时打印点,在 PyPy 下我可以看到点开始变慢,然后只是加速。PyPy 很漂亮。

4

2 回答 2

4

学习维基百科可能比学习实际的源代码更好。A* 在那里写得很清楚。但这感觉像是作弊,不是吗?

与所有好主意一样,A* 实际上在回顾中非常明显。尝试完成它很有趣,并且一路上有一些很好的见解。以下是你如何得到它:

编写蛮力求解器。您将需要在更高级版本中编写的大部分内容:游戏状态,以及从一种状态到另一种状态的描述。您最终还将删除重复的状态。您应该有一个用于考虑状态的队列,一组您已经完成的状态,以及保存迄今为止找到的最佳解决方案的结构。以及一种从队列中获取状态并生成状态的“邻居”状态(可从其到达的状态)的方法。这就是经典人工智能算法的基本结构。请注意,您在技术上是在“生成”或“探索”一个巨大的图表。

之后,添加一个简单的剪枝算法:如果一个状态只剩下一个某种颜色的块,则无需进一步考虑。看看你是否能想出其他修剪算法(即那些将状态标记为“不可解决”的算法)。一个好的剪枝算法将消除许多无意义的状态,从而证明运行剪枝本身所花费的时间是合理的。

然后,引入一个启发式分数:用一个数字对每个状态进行排名,这个数字告诉你这个状态看起来有多“好”——关于它需要多少解决方案。使您的队列成为优先队列。这将允许您首先考虑“最佳外观”状态,因此程序应该更快地提出解决方案。但是,找到的第一个解决方案实际上可能不是最好的,因此要确保找到最好的解决方案,您仍然需要运行整个程序。

存储到达每个状态所需的最低成本(移动次数)。如果您找到更好的路径,请记住更新它。首先取其成本和启发式分数之和最低的状态;这些将更有可能导致更好的解决方案。

A *来了。您需要修改您的启发式函数,使其不会高估到目标的距离,即它可以低于您实际需要的移动次数,但不能更高。然后,请注意,如果您找到了解决方案,其启发式得分将为 0。并且,任何其成本和启发式的总和大于解决方案成本的状态都不会导致更好的解决方案。因此,您可以修剪该状态。但是由于您按顺序处理状态,一旦达到该阈值,您就可以停止并返回,因为队列中的所有其他状态也会被修剪。

现在剩下的就是完善你的启发式方法:它永远不会高估,但它给出的估计越好,A* 所花费的时间就越少。启发式方法越好,您的结果就越好。请注意,启发式算法不会花费太多时间来完成 - 例如,您不希望通过蛮力生成解决方案,即使它会给出完美的答案:)

维基百科有更多的讨论和可能的改进,如果你能做到这一点。但是此时您可以做出的最佳改进可能来自改进启发式函数。

于 2011-10-16T11:15:11.510 回答
1

也许将其转化为经典的规划问题(使用 PDDL 语法)。然后,您可以尝试一些免费提供的规划器。

例如尝试Fast Forward

于 2011-10-16T10:32:06.097 回答