2

Vexed は人気のあるパズル ゲームであり、多くのバージョンが利用可能です (一部は GPL フリー ソフトウェア)。小さな画面のデバイスに非常に適しています。バージョンは、Android、iOS などで利用できます。PalmOS プラットフォームで発見しました。

楽しみのために、困ったレベルを解決するソルバーを書きたいと思います。

Vexed は、ブロック スライディング パズル ゲームです。ルールを簡単に説明すると、次のようになります。

0) 各レベルは四角形のグリッドで、通過できない境界線で囲まれています。どのレベルでも、通過できない塗りつぶされた四角がいくつかあります。さまざまな色のブロックがいくつかあります。これらは、下の境界線、黒の正方形、または他のブロック (異なる色) に置かれている可能性があります。ほとんどのレベルは 8x8 以下です。

1) ブロックを左右にスライドさせる操作しかできません。ブロックが移動する各正方形は、1 回の移動としてカウントされます。

2) 重力があります。ブロックをスライドさせた後、ブロックが塗りつぶされた正方形または別のブロックに置かれなくなった場合、ブロックは別のブロック、塗りつぶされた正方形、または下の境界線に収まるまで落下します。二度と持ち上げることはできませんのでご注意ください。

3) 同じ色のブロックが 2 つ以上接触すると、ブロックは消えます。チェーンが可能であることに注意してください: 支持ブロックが消えると、その上に載っていたブロックが落下し、同じ色のブロックがさらに接触して消える可能性があります。

4) 目標は、最小限の手数ですべてのブロックを消すことです。各レベルには、移動の最小数を示す「標準スコア」があります。(元の PalmOS ゲームでは、「パー スコア」は必ずしも最小ではありませんでしたが、最近プレイしている Android バージョンでは最小です。)

これは、ゲームの PalmOS バージョンのソースを含む SourceForge プロジェクトです。

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

私は経験豊富なソフトウェア開発者ですが、AI に関する作業 (パスファインディング、問題解決など) を実際に行ったことはありません。正しい方向に向けるためのアドバイスを探しています。

現時点では、私が追求すべき基本的な戦略が 2 つあります。

0) すべてのゲームで考えられるすべての解を調べて、すべての解のリスト (最良の解から順に) を返す総当たりソルバを、おそらく速度のために C で作成するだけです。これは合理的なアプローチでしょうか?それとも可能な移動の総数がこれを遅くしすぎますか? 10x10 より大きいレベルは存在しないと思います。

1) いくつかの AI っぽいアルゴリズムを学び、おそらく Python を使用して、問題を解決するためにそれらを巧妙な方法で適用します。

PalmOS Vexed のソースにはソルバーが含まれていることに注意してください。著者によると、「ソルバーは A* とプルーニング ヒューリスティックを使用してソリューションを見つけます。」

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

したがって、私が追求できる戦略の 1 つは、A* アルゴリズムを研究してから、既存のソルバーの C++ コードを研究し、そこから学ぼうとすることです。

これを Python と C タグでタグ付けしますが、何か他のものを使用する必要があると思われる場合は、売り込みを行ってください。検討します!

「バラエティ25パック」のレベルのアスキーアートです。レベル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 の異なるボード構成を考慮して、"Dark Lord" をほぼ正確に 10 秒で解決します。Python 3 ではなく Python 2.x で書いたのは、これを PyPy で試してどれだけ高速になるかを確認したいからです。

次に、これに A* を適用する作業を行う必要があります。「オレンジ色のブロックを別のオレンジ色のブロックに近づけるよりも、別のオレンジ色のブロックに近づける方が良い」のようなメトリックを作成して、それを機能させることができると思います。(最小手数である解が 3 つある場合は、3 つすべてを見たいと思います。)

この Python プログラムに関するコメントを歓迎します。書いてて楽しかった!

編集: PyPy でこれを試しましたが、今まで更新したことはありません。私が PyPy で使用したコンピューターでは、ソルバーは CPython を使用して「Dark Lord」レベルを 10 秒で解決できました。PyPy では 4 秒に短縮されました。クールな部分は、JIT が開始されたときに速度が向上したことです。このプログラムは、動作中にドットを出力します。PyPy では、ドットの開始が遅くなり、その後加速することがわかります。PyPy は気の利いたものです。

4

2 に答える 2

4

ウィキペディアを勉強することは、実際のソース コードを勉強することよりも優れているかもしれません。A* はかなり明確に書かれています。しかし、それは不正行為のように感じますね。

すべての優れたアイデアと同様に、A* は振り返ってみるとかなり明白です。やってみるのは楽しいし、途中でいくつかの素晴らしい洞察があります。方法は次のとおりです。

ブルートフォースソルバーを書きます。より高度なバージョンでは、ゲームの状態と、ある状態から別の状態への移行に関する記述など、多くの記述が必要になります。また、重複した状態を削除することになります。状態を考慮するためのある種のキュー、既に行った一連の状態、およびこれまでに見つかった最良のソリューションを保持するための構造が必要です。そして、キューから状態を取得し、状態の「近隣」状態 (そこから到達可能な状態) を生成するメソッド。これが、従来の AI アルゴリズムの基本構造です。ここでは、技術的に巨大なグラフを「生成」または「探索」していることに注意してください。

その後、単純な剪定アルゴリズムを追加します。状態に色のブロックが 1 つしか残っていない場合は、それ以上考慮する必要はありません。他の剪定アルゴリズム (つまり、状態を「解けない」とマークするアルゴリズム) を思い付くことができるかどうかを確認してください。優れたプルーニング アルゴリズムは、多くの無意味な状態を排除するため、プルーニング自体の実行にかかる時間を正当化します。

次に、ヒューリスティック スコアを導入します。各状態を、その状態がどの程度「良い」かを示す数値でランク付けします。つまり、あとどれだけの解決が必要かを示します。キューをプライオリティ キューにします。これにより、「最も見栄えの良い」状態を最初に考慮することができるため、プログラムはより迅速解決策を見つけることができます。ただし、最初に見つかったソリューションが実際には最適ではない可能性があるため、最適なソリューションを確実に見つけるには、プログラム全体を実行する必要があります。

各状態に到達するためにかかった最小コスト (移動数) を保存します。より良いパスが見つかったら、忘れずに更新してください。コストとヒューリスティック スコアの合計が最小の状態を最初に取得します。それらはより良い解決策につながる可能性が高くなります。

そしてA*が登場。ヒューリスティック関数を変更して、目標までの距離を過大評価しないようにする必要があります。つまり、実際に必要な移動数よりも少なくなる可能性がありますが、高くなることはありません。次に、ソリューションが見つかった場合、そのヒューリスティック スコアは 0 になることに注意してください。また、そのコストとヒューリスティックの合計がソリューションのコストを超える状態は、より良いソリューションにつながることはありません。したがって、その状態を切り取ることができます。ただし、状態を順番に取っているため、そのしきい値に達したら、停止して戻ることができます。これは、キュー内の他のすべての状態も同様に削除されるためです。

あとは、ヒューリスティックを完成させるだけです。過大評価することはありませんが、より適切な推定値が得られるほど、A* にかかる時間は短くなります。ヒューリスティックが優れているほど、結果が向上します。ヒューリスティックが完了するのにそれほど時間がかからないように注意してください。たとえば、完璧な答えが得られたとしても、力ずくでソリューションを生成したくないでしょう:)

ウィキペディアには、ここまで来れば、さらに議論があり、改善の可能性があります。しかし、この時点でできる最善の改善は、ヒューリスティック関数の改善から得られる可能性があります。

于 2011-10-16T11:15:11.510 に答える
1

おそらく、それを古典的な計画の問題に変換します (PDDL 構文を使用)。次に、無料で利用できるいくつかのプランナーを試すことができます。

たとえば、Fast Forwardを試してください。

于 2011-10-16T10:32:06.097 に答える