人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

タイルベーススクロール

前回までのプログラムは何かよくあるRPGと違うような違和感ありませんでした?実は一般的なRPGではプレイヤーはつねに画面の真ん中にいてマップの方がスクロールする方法をとっています。たとえば、ドラクエなんかはそうです。今回はドラクエのようにプレイヤーが移動するとマップがスクロールするように作り直します。これはマップの座標と画面の座標を明確に区別し、オフセットという考え方を導入すると簡単に実現できます。

とりあえずスクリプトをダウンロードして前回のと比べてみてください。移動したときの表示がまったく違っていることがわかると思います。くどいですが「プレイヤーはつねに画面の真ん中にいる」というのがミソです。

pyrpg09.zip
f:id:aidiary:20100606111745p:plain

スクロール処理

RPGのマップは画面に表示される範囲よりずっと大きいためマップ全体は一度に表示しきれず見える部分のみ表示します。そして、プレイヤーを移動させると見える部分が徐々に変わっていくというのがスクロールの仕組みです。

f:id:aidiary:20100606111746p:plain

ここでは説明を簡単にするため上のような全体マップが5x5マス、画面サイズが3x3マスと想定します。プレイヤーがマップの中央のマスから上に1 歩移動したと考えると通常のRPGだと下のように見え方が変わります。画面のサイズが3x3しかないのでマップ全体は表示しきれないことに注意してください。この図を見るとプレイヤーは常に画面の中央にいてマップで表示される部分が上に1マスずれたことがわかります。これがスクロールの本質です。上下左右のどの方向の移動でもマップの見える範囲がずれる点ではまったく同じです。

f:id:aidiary:20100606111747p:plain

オフセットの計算

マップスクロールではマップ上での座標と画面上の座標を区別することが非常に重要になります。スクリプトの説明をする前に例を用いて説明します。

f:id:aidiary:20100606111748p:plain

上の例ではマップのサイズを256マスx256マス(1マス32ピクセルなので8192x8192ピクセル)としています。ドラクエのフィールドマップは大体これくらいの大きさです。もちろんパソコンの画面にこんなに大きなマップを一度に表示しきれるわけないのでその一部分を表示することになります。画面に表示される範囲は上の図の赤い四角形でかこった部分です。サイズはSCR_RECTで定義しているように640x480ピクセルです(比があってないのは図をみやすくするためです)。

ここでマップ上での座標と画面上での座標の違いに注意してください。たとえば、図の場合、マップ上でのプレイヤーの座標は(4096,4096)、つまり、プレイヤーはマップのちょうど中央にいるとします。一方、画面上での座標はプレイヤーはつねに画面の真ん中なので(320,240)になります。

 マップ上での座標 (4096,4096)
 画面上での座標  (320,240)

プレイヤーの座標だけではなく、マップの他の座標でも同じように計算します。たとえば、マップ上の(3776,3856)のマスは画面上だと (0,0)にあたります。

 マップ上での座標 (4096-320, 4096-240) = (3776, 3856)
 画面上での座標  (0,0)

プレイヤーの位置やマップの位置はつねにマップ上での座標で格納されています。これを画面に描画するためにはマップ上での座標を画面上での座標に変換する必要があります。このために必要なのがオフセットという考え方です。一言でいうとオフセットとは画面上での座標(0,0)に対応するマップ上での座標のことです。上の例では(3776,3856)がオフセットです。マップ上での座標を画面上での座標に変換するには

 オフセット = プレイヤーのマップ上での座標 - 画面の半分のサイズ
 画面上での座標 = マップ上での座標 - オフセット

となります。画面の半分のサイズというのはスクリーンサイズが640x480のときは(320,240)、800x600なら(400,300)などとなります。描画するときは画面上の座標が必要です。たとえば、(4096, 4096)の位置にいるプレイヤーを描画するにはオフセット(3776,3856)をひいた画面の(320,240)の位置に描画します。(3776,3856)の位置にあるマップチップを描画するにはオフセット(3776,3856)をひいた画面の(0,0)の位置に描画します。他のマスも同様です。オフセットはプレイヤーの座標に依存しているので、プレイヤーが移動しマップ上での位置が変わると再計算が必要な点に注意してください。

では次にスクリプトを見てみます。上の考え方をそのまま実装しています。

    def calc_offset(player):
        """オフセットを計算する"""
        offsetx = player.rect.topleft[0] - SCR_RECT.width/2
        offsety = player.rect.topleft[1] - SCR_RECT.height/2
        return offsetx, offsety

プレイヤーのマップ上での座標はplayer.rectに格納されています。topleft[0]でX座標、topleft[1]でy座標が得られます。これに画面の半分のサイズをひいてオフセットを求めています。プレイヤーやマップを描画するときは画面の座標に変換する必要があるためdraw()にオフセットを渡しています。まずプレイヤーの描画関数です。

    def draw(self, screen, offset):
        """オフセットを考慮してプレイヤーを描画"""
        offsetx, offsety = offset
        px = self.rect.topleft[0]
        py = self.rect.topleft[1]
        screen.blit(self.image, (px-offsetx, py-offsety))

pxとpyはプレイヤーのマップ上での座標です。blit()で描画するときはこれにoffsetをひいて画面上での座標に変換しています。次にマップの描画関数です。

    def draw(self, screen, offset):
        """マップを描画する"""
        offsetx, offsety = offset
        # マップの描画範囲を計算
        startx = offsetx / GS
        endx = startx + SCR_RECT.width/GS + 1
        starty = offsety / GS
        endy = starty + SCR_RECT.height/GS + 1
        # マップの描画
        for y in range(starty, endy):
            for x in range(startx, endx):
                # マップの範囲外はデフォルトイメージで描画
                # この条件がないとマップの端に行くとエラー発生
                if x < 0 or y < 0 or x > self.col-1 or y > self.row-1:
                    screen.blit(self.images[self.default],
                                (x*GS-offsetx,y*GS-offsety))
                else:
                    screen.blit(self.images[self.map[y][x]],
                                (x*GS-offsetx,y*GS-offsety))

マップの場合、ピクセル単位の座標だけではなく、マス単位の座標も使うのでGS(マスのサイズ)で割ってマップの描画範囲を計算しています。先の図の赤い四角形の一番左上の座標(マス単位)が(startx,starty)で右下の座標(マス単位)が(endx,endy)にあたります。+1しているのはスクロールしたときに背景が見えないようにするためです(+1を外すとなぜ必要かわかります)。blit()で描画するときにoffsetをひいているのはプレイヤーと同じです。オフセットの求め方をスクリプトに対応した形でまとめた図をはっておきます。

f:id:aidiary:20100606111749p:plain

マップ範囲外では?

最後にマップの端にプレイヤーが移動したときの処理を考えます。

f:id:aidiary:20100606111750p:plain

上図のようにプレイヤーがマップ上の一番左上に移動したとき、画面に表示される範囲(赤い四角形)はマップからはみ出ます。これを解決するには

  1. マップの端っこではスクロールしない(赤い四角形がマップの四角形におさまるようにする)
  2. マップの範囲外ではデフォルトのマップチップで描画する
  3. マップをループして反対側のマップで描画する

の3通りがあります。ドラクエの場合、お城や町などのマップでは2番目、フィールドマップでは3番目の方法が使われています。ここでは、2番目の方法を使います。先ほどのマップの描画関数で

                if x < 0 or y < 0 or x > self.col-1 or y > self.row-1:
                    screen.blit(self.images[self.default],
                                (x*GS-offsetx,y*GS-offsety))

の部分です。このコードでマップの範囲外ではデフォルトのマップチップ(self.default)で描画しています。フィールドの場合、海のマップチップ

f:id:aidiary:20100605233458p:plain

を使ってます。ただマップの範囲外にプレイヤーが移動できないようにしておく必要はあります。表示される海のマスはあくまで見せかけにすぎないからです。世界の境界から出ようとする者は見えない壁にぶつかります(笑)デフォルトのマップチップはマップデータに追加しています。

15 20
1
11111111111111111111
10000000000000000001
10003333300000000001
10004444400000000001
10000022222200000001
10000002222222000001
10000002211222200001
10000002211220000001
10000000222200000001
10000000000044400001
10000000004440000001
10000444444000000001
10000333300000000001
10000000000000000001
11111111111111111111

2行目の1がデフォルトマップチップです。この場合、海と同じマップチップを使ってます。0にかえて実行してみたりしてください。これに合わせてデフォルト値を読み込むようにマップのロード関数も少し修正しています。

    def load(self):
        """ファイルからマップをロード"""
        file = os.path.join("data", self.name + ".map")
        # テキスト形式のマップを読み込む
        fp = open(file)
        lines = fp.readlines()
        row_str, col_str = lines[0].split()  # 行数と列数
        self.row, self.col = int(row_str), int(col_str)
        self.default = int(lines[1])  # デフォルト値
        for line in lines[2:]:
            line = line.rstrip()
            self.map.append([int(x) for x in list(line)])
        fp.close()

スクリプトを動かしてみるとわかりますが、これでもドラクエの移動とは少し違いますよね?ドラクエの移動はマス単位ではなく、ピクセル単位のスクロールをしているからです。次回取り上げます。