BETA

はじめてのPythonで「パネルでポン」の解法ツールを作ってみた

投稿日:2018-12-24
最終更新:2018-12-24

 こんにちは、べんぞうさんです。

 Pythonを勉強し始めて早2ヶ月。基本的な文法をざっと覚えたので何か作ってみたいと思い、「パネルでポン」の解法ツールに挑戦してみました。

「パネルでポン」とはなんぞや?

 1995年にスーパーファミコンで発売されたパズルゲームです。


1995 Nintendo/INTELLIGENT SYSTEMS
出典:任天堂ホームページ
https://www.nintendo.co.jp/titles/20010000001545

 上図中央がゲーム画面です。縦12×横6マスのエリアに6種類(紫・黄・水・青・緑・赤)のパネルが配置されています。プレイヤーは横に並んだ2つのパネルを入れ替え、縦または横に同じ色を3つ以上揃えることで対象のパネルを消すことができます。画像では四隅を白枠で囲まれた2つのパネル(紫・水)がありますが、これがプレイヤーの選択しているパネルです。この状態でパネルを入れ替えると紫が縦に3つ揃って消えることになります。プレイヤーの操作が横に並んだパネルの入替に限られているので、解法ツールも作りやすそうです。

 今回は「パネルでポン」の1人用モードに収録されている「パズルモード」のステージ59を解いていこうと思います。余談になりますが、「パネルでポン」はミニスーパーファミコンに収録されていて、このステージ59はどうしても自力で解けずに諦めてしまった苦い思い出のステージです(パズルモードは全60ステージなのでクリア目前だった……)。Pythonの力を借りてリベンジするのだ!!

ステージ59とクリア条件

 まずはステージ59の画像から。


1995 Nintendo/INTELLIGENT SYSTEMS

 右上に「04」とあるのはパネルの入替可能回数です。ステージ59は4回のパネル入替ですべてのパネルを消す必要があります。

 次にパネルの色を数値に置き換えてみます。
  - 紫:1
  - 黄:2
  - 水:3
  - 青:4
  - 緑:5
  - 赤:6
  - パネルなし:0

 そして、ステージ59のパネル配置を上記の数値に置き換えます。
   [0,0,0,0,0,0]
   [0,0,0,0,0,0]
   [0,0,0,0,0,0]
   [0,0,5,0,0,0]
   [0,0,1,0,0,0]
   [0,0,3,0,0,0]
   [0,1,4,0,0,0]
   [0,2,4,2,0,0]
   [0,4,1,5,0,0]
   [0,2,1,5,0,0]
   [0,1,3,1,0,0]
   [0,1,3,1,0,0]

 それっぽくなってきました。4回のパネル入替でこの72マスをすべて0にすればクリアです。必要な機能を実装していきましょう。

機能の実装

 パネルに対する入替落下消え判定がメインになります。

◆パネルの入替

 左下を(x,y)=(0,0)、右上を(x,y)=(5,11)として、選択箇所をx,y座標で表現します。なお、選択している横2マスの左側を(x,y)、右側を(x+1,y)とします。そしてこの左右のパネルを入れ替えます。

 def switch_panel(self,x,y):  
  #(x,y)と(x+1,y)のパネルを入れ替える  
        leftpanel = self.panel_status[y][x]  
        rightpanel = self.panel_status[y][x+1]  

        self.panel_status[y][x] = rightpanel  
        self.panel_status[y][x+1] = leftpanel  

 panel_status はステージ59全体のパネル情報をNumpy配列で記録しています。対象のパネルをx,yで指定しているのですが、xとyがひっくり返っているのが気持ち悪いですね……。これはステージ59のデータを

difficult=np.array([[0,1,3,1,0,0],[0,1,3,1,0,0],[0,2,1,5,0,0],[0,4,1,5,0,0],[0,2,4,2,0,0],[0,1,4,0,0,0],[0,0,3,0,0,0],[0,0,1,0,0,0],[0,0,5,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]])

 こんな形式で保持しているためです。横1列をひとまとまりとしていますが、実装した後で「あれ……? これ……逆になるな……」と気付きました。

 np.array([[0,0,0,0,0,0,0,0,0,0,0,0],[1,1,2,4,2,1,0,0,0,0,0,0]…) のように縦1列のまとまりで考えればよかった。直観的に画面のイメージと繋がらなかったので最初のまま作ってしまいましたが、後々まで残るややこしさの元になってしまったのが反省点です。

 また、入替用の変数(leftpanel,rightpanel)を作っていますが、こんなことをしなくても要素の入替はできるような気がする……。

◆パネルの落下

 「対象パネルの下にあるパネルのないマス数」= 対象パネルの落ちるマス数として、縦1列ずつを下からチェックしていきます。

    def drop_panel(self):  
        for xrange in range(0,6):  
            bottomline =0  
            emptypanel=0  
            for yrange in range(0,12):  
                if self.panel_status[yrange][xrange] == 0:  
                    emptypanel += 1  
                elif emptypanel == 0:  
                    bottomline += 1  
                else:  
                    self.panel_status[yrange-emptypanel][xrange]=self.panel_status[yrange][xrange]  
                    self.panel_status[yrange][xrange]=0  
                    bottomline += 1  

 まずチェックするマスにパネルがあるかをチェック。なければemptypanelをインクリメントします。パネルがある時はemptypanelの値を調べ、0であればパネルは落ちないので何もしない。0以外であればy座標からemptypanelを引いた場所に対象パネルの要素を移し、元々のパネル位置には0を入れます。

 以上のチェックを72マスすべてで実行します。
 うーむ……底になるy座標を記録する変数、bottomlineは不要だったのね……。

パネルの消え判定

 ここはいろいろと工夫しました。

    def delete_panel(self):  
        #消えるパネルがないか画面を全探索して処理  
        for yrange in range(0,12):  
            for xrange in range(0,6):  
                if self.panel_status[yrange][xrange] == 0: #パネルがなければ次の位置へ  
                    self.panel_mask[yrange][xrange]=1  
                    continue  
                else: #同色パネルチェック  
                    if xrange in(0,1,2):  
                        xwks = 0  
                        xwke = xrange + 2  
                    else:  
                        xwks = xrange - 2  
                        xwke = 5  

                    if yrange in(0,1,2):  
                        ywks = 0  
                        ywke = yrange + 2  
                    elif yrange in(9,10,11):  
                        ywks = yrange - 2  
                        ywke = 11  
                    else:  
                        ywks = yrange - 2  
                        ywke = yrange + 2  

                    xlp = xwke - xwks -1  
                    ylp = ywke - ywks -1  

                    for xch in range(0,xlp):  
                        if self.panel_status[yrange][xwks+xch] == self.panel_status[yrange][xwks+1+xch] == self.panel_status[yrange][xwks+2+xch]:  
                            self.panel_mask[yrange][xrange]=0  
                            break  
                        else:  
                            self.panel_mask[yrange][xrange]=1  

                    if self.panel_mask[yrange][xrange] == 1:  
                        for ych in range(0,ylp):  
                            if self.panel_status[ywks + ych][xrange] == self.panel_status[ywks + 1 + ych][xrange] == self.panel_status[ywks + 2 + ych][xrange]:  
                                self.panel_mask[yrange][xrange]=0  
                                break  
                            else:  
                                self.panel_mask[yrange][xrange]=1  

        self.panel_status = self.panel_status * self.panel_mask  

 まずゲーム画面と同じ大きさのNumpy配列panel_maskを作りました。panel_maskのすべての要素を1で初期化しておき、パネルが消えると判定された位置は要素を0にします。全72マスの判定が終わったらpanel_statuspanel_maskを掛け合わせ、消えるパネルを0にした配列でpanel_statusを更新しています。

 最初はpanel_statusを直接更新していたのですが、パネルが同時に4つ・5つと消えたり、縦・横が同時に消える時の場合分けがうまく整理できなかったのでワンクッション置く方式を取りました。対象のパネルが消えるか消えないかだけ分かればいいので、下図の6パターンをチェックすればいいことになります。

 だいぶシンプルになったはずなのに、消えるかどうかの判定コードがずいぶん冗長になってしまいました。x=0の時、左と下はチェックしなくていいなどの場合分けが美しくないので、ここはもう少し工夫したいところです。

クラス完成!

 必要なパーツは揃いました。ここまでの機能をTrialStateクラスとしてまとめます。

import numpy as np  
import copy  

class TrialState():  
    def __init__(self):  
        self.x=0  
        self.y=0  
        self.panel_mask = np.ones((12,6))  

    def initial_panel(self,panel_start):  
        self.panel_default = copy.deepcopy(panel_start)  
        self.panel_status = copy.deepcopy(self.panel_default)  
        self.x = 0  
        self.y = 0  

    def rollback_panel(self):  
        self.panel_status = copy.deepcopy(self.panel_default)  

    def initial_panel_mask(self):  
        self.panel_mask = np.ones((12,6))  

    def switch_panel(self,x,y):  
        #(x,y)と(x+1,y)のパネルを入れ替える  
        leftpanel = self.panel_status[y][x]  
        rightpanel = self.panel_status[y][x+1]  

        self.panel_status[y][x] = rightpanel  
        self.panel_status[y][x+1] = leftpanel  

    def move_next(self):  
        flg = True  
        if self.x <= 3:  
            self.x += 1  
        elif self.y <=10:  
            self.x = 0  
            self.y += 1  
        else:  
            flg = False  
        return flg  

    def drop_panel(self):  
        #落ちるパネルがないか画面を全探索して処理  
        for xrange in range(0,6):  
            bottomline =0  
            emptypanel=0  
            for yrange in range(0,12):  
                if self.panel_status[yrange][xrange] == 0:  
                    emptypanel += 1  
                elif emptypanel == 0:  
                    bottomline += 1  
                else:  
                    self.panel_status[yrange-emptypanel][xrange]=self.panel_status[yrange][xrange]  
                    self.panel_status[yrange][xrange]=0  
                    bottomline += 1  

    def delete_panel(self):  
        #消えるパネルがないか画面を全探索して処理  
        for yrange in range(0,12):  
            for xrange in range(0,6):  
                if self.panel_status[yrange][xrange] == 0: #パネルがなければ次の位置へ  
                    self.panel_mask[yrange][xrange]=1  
                    continue  
                else: #同色パネルチェック  
                    if xrange in(0,1,2):  
                        xwks = 0  
                        xwke = xrange + 2  
                    else:  
                        xwks = xrange - 2  
                        xwke = 5  

                    if yrange in(0,1,2):  
                        ywks = 0  
                        ywke = yrange + 2  
                    elif yrange in(9,10,11):  
                        ywks = yrange - 2  
                        ywke = 11  
                    else:  
                        ywks = yrange - 2  
                        ywke = yrange + 2  

                    xlp = xwke - xwks -1  
                    ylp = ywke - ywks -1  

                    for xch in range(0,xlp):  
                        if self.panel_status[yrange][xwks+xch] == self.panel_status[yrange][xwks+1+xch] == self.panel_status[yrange][xwks+2+xch]:  
                            self.panel_mask[yrange][xrange]=0  
                            break  
                        else:  
                            self.panel_mask[yrange][xrange]=1  

                    if self.panel_mask[yrange][xrange] == 1:  
                        for ych in range(0,ylp):  
                            if self.panel_status[ywks + ych][xrange] == self.panel_status[ywks + 1 + ych][xrange] == self.panel_status[ywks + 2 + ych][xrange]:  
                                self.panel_mask[yrange][xrange]=0  
                                break  
                            else:  
                                self.panel_mask[yrange][xrange]=1  

        #panel_statusを更新                          
        self.panel_status = self.panel_status * self.panel_mask  

    def clear_check(self):  
        chk = False  
        for yrange in range(0,12):  
            for xrange in range(0,6):  
                if self.panel_status[yrange][xrange] != 0:  
                    chk = True  
                    break  
                else:  
                    continue                   
            break  

        return not chk  

 パネルを初期設定の配置に戻すrollback_panel、チェックするパネルの位置を移動するmove_next、すべてのパネルが消えたかを判定するclear_checkなどを追加しています。さて! いよいよこのクラスを使ってステージ59を解いてみますよ~。

解析スタート!!

 パターンの総当たりで解答を探していきます。TrialStateクラスからインスタンスを4つ(入替可能回数)作成し、Trial1に初期パネル配置を設定。左下の(0,0)と(1,0)を入れ替え、パネルの消え・落ちの判定を行った後のパネル配置をTrial2の初期配置に渡します。

 Trial2でも同様に(0,0)(1,0)のパネルを入れ替え、パネルの消え・落ちの判定後、パネル配置をTrial3に渡します。Trial3からTrial4も同じ。そして、Trial4ではパネルの入れ替え・消え・落ちの判定後、パネルがすべて消えたかの判定を行います。

 パネルがすべて消えなかった場合はTrial4のパネル配置を初期値(Trial3から受け取った時点)に戻し、パネル選択位置をx方向に1つ移動します。Trial4の全パターンをチェックしたらTrial3を1つ移動して、Trial4を最初からチェック。Trial3の全パターンをチェックしたらTrial2、Trial2をチェックしたらTrial1と戻っていって全パターンを探索します。文章だとちょっとややこしいので図にまとめてみました。





 途中でパネルがすべて消える組み合わせを見つけたらクリア。その時点のTrial1~Trial4のパネル選択位置(x,y)を表示して処理終了となります。

 以上の処理をこんな感じで実装してみました。

import numpy as np  

#入替可能回数分、TrialStateをインスタンス化  
Trial1=TrialState()  
Trial2=TrialState()  
Trial3=TrialState()  
Trial4=TrialState()  

#すべてのパネルが消えたかのフラグ  
clr = False  

#パネル選択位置が最終地点(5,11)まで到達していないかのフラグ  
flg1 = True  
flg2 = True  
flg3 = True  
flg4 = True  

#パネル初期配置設定  
difficult=np.array([[0,1,3,1,0,0],[0,1,3,1,0,0],[0,2,1,5,0,0],[0,4,1,5,0,0],[0,2,4,2,0,0],[0,1,4,0,0,0],[0,0,3,0,0,0]\  
                            ,[0,0,1,0,0,0],[0,0,5,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]])  

#1回目のパネル入替インスタンス開始  
#Trial1にパネルの初期配置をセット  
Trial1.initial_panel(difficult)  

while flg1 == True and clr == False:  
    Trial1.switch_panel(Trial1.x,Trial1.y)  

    #パネル入替後の消え・落ちをチェック  
    delck = True  
    while delck == True:  
        Trial1.drop_panel()  
        Trial1.delete_panel()  
        #panel_maskが初期値のままならループを抜ける  
        if Trial1.panel_mask.min() == 1:  
            delck = False  

    #2回目のパネル入替インスタンス開始  
    #Trial2の初期配置にTrial1の配置をセット  
    Trial2.initial_panel(Trial1.panel_status)  

    flg2 = True  
    while flg2 == True and clr == False:  
        Trial2.switch_panel(Trial2.x,Trial2.y)  

        #パネル入替後の消え・落ちをチェック  
        delck = True  
        while delck == True:  
            Trial2.drop_panel()  
            Trial2.delete_panel()  
            if Trial2.panel_mask.min() == 1:  
                delck = False  

        #3回目のパネル入替インスタンス開始  
        #Trial3の初期配置にTrial2の配置をセット  
        Trial3.initial_panel(Trial2.panel_status)  
        flg3 = True  

        while flg3 == True and clr == False:  
            Trial3.switch_panel(Trial3.x,Trial3.y)  

            #パネル入替後の消え・落ちをチェック  
            delck = True  
            while delck == True:  
                Trial3.drop_panel()  
                Trial3.delete_panel()  
                if Trial3.panel_mask.min() == 1:  
                    delck = False  

            #4回目のパネル入替インスタンス開始  
            #Trial4の初期配置にTrial3の配置をセット  
            Trial4.initial_panel(Trial3.panel_status)  
            flg4 = True  

            while flg4 == True and clr == False:  
                Trial4.switch_panel(Trial4.x,Trial4.y)  

                #パネル入替後の消え・落ちをチェック  
                delck = True  
                while delck == True:  
                    Trial4.drop_panel()  
                    Trial4.delete_panel()  
                    if Trial4.panel_mask.min() == 1:  
                        delck = False  

                #クリア判定  
                if Trial4.clear_check():  
                    clr = True  
                else:  
                    flg4 = Trial4.move_next()  
                    if flg4:  
                        Trial4.rollback_panel()  
                    else:  
                        flg3 = Trial3.move_next()  
                        if flg3:  
                            Trial3.rollback_panel()  
                            break  
                        else:  
                            flg2 = Trial2.move_next()  
                            if flg2:  
                                Trial2.rollback_panel()  
                                break  
                            else:  
                                flg1 = Trial1.move_next()  
                                if flg1:  
                                    Trial1.rollback_panel()  
                                    break  
                                else:  
                                    print("解なし")  


print(Trial1.x,Trial1.y)  
print(Trial2.x,Trial2.y)  
print(Trial3.x,Trial3.y)  
print(Trial4.x,Trial4.y)  

 それではいよいよ実行です! 総パターン数は60の4乗で1000万超!(選択セルが横に並んでいるため、選択可能範囲は1列減った5列×12行の60パターン) 果たしてどのくらいで解答が導き出されるのか? 処理を走らせ、ゾンビランドサガを見ながら待ちます。

 1話分を見終わった時点でディスプレイに目をやると、

  3 0
  2 3
  3 1
  1 3

 と表示されていました。これが神託か……? とメモを取り、ミニスーファミを立ち上げます。座標が0スタートであることを忘れないようにして、該当のパネルを入れ替えていきます。うまくいくのか……?

初期位置

1995 Nintendo/INTELLIGENT SYSTEMS

(3 0)

1995 Nintendo/INTELLIGENT SYSTEMS

(2 3)

1995 Nintendo/INTELLIGENT SYSTEMS

(3 1)

1995 Nintendo/INTELLIGENT SYSTEMS

(1 3)

1995 Nintendo/INTELLIGENT SYSTEMS


1995 Nintendo/INTELLIGENT SYSTEMS


1995 Nintendo/INTELLIGENT SYSTEMS


1995 Nintendo/INTELLIGENT SYSTEMS


1995 Nintendo/INTELLIGENT SYSTEMS


1995 Nintendo/INTELLIGENT SYSTEMS

 クリアーー!!!! ステージ59クリアで~す!!!

反省会

 というわけで「パネルでポン」の解法ツールが完成しました。最初は2~3日でできると思っていましたが、なんだかんだで2週間ほどかかってしまいました。パネル消え判定のアイデアを思いついたのはちょっと自分を褒めてあげたい。

 メイン処理のWhileループを抜け出すやり方はネットで見つけたスマートなやり方を参照しました。これまで我流でコードを書いていましたが、一般的なパターンを勉強して、いわゆる「車輪の最発明」を避けるようにする必要があると感じました。自分のコードは無駄が多くて古くさい印象です。

 具体的な反省として、Pythonでの変数の取り扱いがまだよくわかっていないなと実感しました。とりあえずdeepcopyしておけば大丈夫だろうという感じだったので、変数については知識の縦糸を通しておきたいです。それと、for文はfor~in range()を使いましたが、Pythonなのでイテレータで記述したいところでした。

 今回はとりあえず動くものをPythonで作ることが目標だったので、ここからの作りこみはしないつもりですが、3ヶ月くらいたってもう少し強くなったら色々改良してみたいです。

 上記の「とりあえず動くものをPythonで作る」という他に「作成物と作成経過を何かしらの形で公開する」というのが大きな目的だったんですが、こういった技術的な文章を書くのにこんなに時間がかかるとは思いませんでした。ツールを作った時間の倍は使っています……。だいぶ慣れてはきたので、次はもう少し時間短縮をしたいですね。

 とりあえず、1ヶ月以上かかったミッション終了~。長いエントリになってしまいましたが、ここまで読んでくださった方、ありがとうございました!

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@SDBtctb5RMYlUy0xの技術ブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう