フェーズ 1 ステージを画面に並べる

5 枚のタイルを描き、文字で書いた地図を盤面として表示する

トップページstep2 概要 / フェーズ 1

ゴール

リソースエディタで 8×8 ドットのタイルを 5 枚描き、 文字で書いた地図をプログラムに読ませて、盤面を画面の中央に並べます。 このフェーズで書くコードは 94 行(空行とコメントを除くと 70 行)で、まだ何も動きません。

このフェーズで作るファイル step2/ フォルダを作り、その中に次の 2 つを置きます。 フォルダとファイルはご自身で作ってください。
step2/
├── sokoban.py       これから書くコード
└── sokoban.pyxres   リソースエディタで作る絵
名前は好きなもので構いませんが、以降のページはこの名前で説明します。

1. 考え方

1-1. 絵は「キャラクター」ではなく「マス」になる

step1 の絵(男の子・お化け・お菓子)は、画面のどこにでも置ける独立した絵でした。 倉庫番の絵はちがいます。8 ドットごとにきっちり区切られたマス目の、その 1 マスを埋める絵です。 だから 1 枚 8×8 ドット、すきまなく塗りつぶすのが基本になります。

用意するのは次の 5 枚です。

タイル地図での文字役割透過
. 荷物も主人公も自由に通れるマス。いちばん地味に描くなし
# 誰も通れないマス。床とはっきり区別がつく色になし
ゴールO 荷物を乗せたい場所。床の一種なので、通ることはできるなし
荷物$ 押して動かすもの。画面でいちばん目立つ色にあり
主人公@ 今回は下向きの 1 枚だけ。上・左・右の 3 枚はフェーズ 2、 足踏みの 2 コマ目 4 枚はフェーズ 4 で描くあり
床も、ちゃんと絵を描きます 「何もないところは黒いままでいいのでは」と思うかもしれませんが、それだと 盤面がどこからどこまでなのか分からなくなります。 倉庫番は盤面の形そのものがパズルの問題文なので、 床を描いて「ここは盤面の中だ」と見せる必要があります。
ただし床は目立たせてはいけません。主役は荷物と主人公です。 暗い色を 1〜2 色使って、うっすら模様が見える程度に留めます。

1-2. 絵の置き場所を、最後まで見越して決める

step1 フェーズ 1 と同じで、イメージバンクのどこに描くかが、そのままコードの数字になります。 今回はさらに厄介で、フェーズ 4 で主人公が 8 枚に増えます。 あとから場所を動かすとコードを全部直すことになるので、 いま描かない 8 枚ぶんの場所まで、先に決めてしまいます

イメージバンク 0 の左上(実線=このフェーズで描く/点線=あとのフェーズで描く) u=0u=8u=16 u=24u=32 v=0v=8v=16 ゴール 荷物 乗った荷物 ← フェーズ 5 下 コマA 上 コマA左 コマA右 コマA 下 コマB上 コマB 左 コマB右 コマB 主人公の場所は、式で出せます u = 向き × 8 v = 8 + コマ × 8 向き … 下 0 / 上 1 / 左 2 / 右 3 コマ … A 0 / B 1 点線の 7 枚(主人公の残り)はフェーズ 4 で、乗った荷物はフェーズ 5 で描きます この並べ方にしておくと、フェーズ 4 で if を 8 回書かずに済みます
タイルは 1 段目、主人公は 2 段目以降。いま描くのは実線の 5 枚だけです
なぜ主人公を「下・上・左・右」の順にするのか この順番には意味があります。フェーズ 2 で、矢印キーと進む向き(0, 1)(下)、(0, -1)(上)、(-1, 0)(左)、(1, 0)(右) という並びで持つので、同じ番号で絵も引けるようにしておくと、あとで話が早くなります。 いまは「そういう約束にした」とだけ覚えておいてください。

1-3. ステージは「文字の地図」で書く

盤面をコードで表すのに、文字を並べた地図を使います。

STAGE1 = [
    "########",
    "#......#",
    "#..O...#",
    "#..$...#",
    "#..@...#",
    "########",
]

このやり方の良いところは、コードの上で盤面がそのまま目に見えることです。 数字の表で書くと [[1,1,1,1],[1,0,0,1]] のようになり、 どんな形なのか読み取れません。文字なら、書きながら形を確かめられます。 フェーズ 6 でステージを 10 個に増やすとき、この読みやすさが効いてきます。

文字意味覚え方
#格子模様が壁っぽい
.何もない
Oゴール丸い目印(数字のゼロではなく、大文字のオー)
$荷物倉庫番の慣例。荷物=お金
@主人公倉庫番の慣例。人の頭
行の長さは、必ずそろえてください "#......#" は 8 文字です。どの行も同じ文字数にします。 1 行だけ短いと、盤面の右端が欠けたり、あとのフェーズで IndexError(範囲の外を見にいった、というエラー)が出たりします。 地図を書いたら、いちど縦に見て、右端がまっすぐそろっているかを確かめてください。

1-4. 地図を、地形・主人公・荷物の 3 つに分ける

ここがこのフェーズの本題です。読み込んだ地図を、そのまま 1 つの表として持ってはいけません3 つに分けて持ちます

文字の地図 ######## #......# #..O...# #..$...# #..@...# ######## 縦が y(上から) 横が x(左から) ① 地形 self.tiles ######## #......# #..O...# #......# #......# ######## ← 荷物と主人公は   床にすり替える ② 主人公 player_x, player_y = 3, 4 ③ 荷物 boxes = [[3, 3]] 荷物は何個でもよいので、リストで持つ ④ 重ねて描く
地形は書き換えられる二次元リストに、主人公と荷物は位置の数字にして持ちます
なぜ分けるのか 1 つの表に全部入れておくと、荷物を動かしたときに「その下にゴールがあったかどうか」を 自分で覚えておかないといけません。荷物がゴールから出ていったのに床に戻し忘れて、 ゴールが消えてしまう——倉庫番でいちばんよくある不具合です。
地形(変わらないもの)と、動くもの(主人公・荷物)を分けておけば、この問題は起きません。 荷物は地形の上に重ねて描くだけなので、どいた瞬間に下の地形がそのまま見えます。

1-5. マス目をドットに直して、盤面を中央に置く

盤面のマス目は (x, y)=「左から何番目・上から何番目」ですが、 pyxel.blt() が求めるのはドットの座標です。変換はかけ算 1 つで済みます。

ドットの x = ox + マス目の x × 8
ドットの y = oy + マス目の y × 8

この oxoy盤面を中央に寄せるためのずらし幅です。 ステージによって盤面の大きさが変わるので、読み込むたびに計算し直します

画面 160×120 ドット(図は 2 倍) HUD_HEIGHT = 12(フェーズ 8 で手数を出す場所) ox = 48 oy = 42 盤面 8×6 マス = 64×48 ドット ox = (160 - 64) // 2 = 48  oy = 12 + (120 - 12 - 48) // 2 = 12 + 30 = 42
上の 12 ドットはいまは使いませんが、フェーズ 8 で手数を出すために空けておきます
いま HUD の場所を空けておく理由 盤面はいちばん大きいステージで 18×12 マス=144×96 ドットになります。 画面が 160×120 なので、縦の余白は 24 ドットしかありません。 あとから「手数を出す場所がない」と気づくと、盤面の位置を全部ずらすことになります。 いちばん狭くなるときを先に確かめておくのは、画面を作るときの基本です。

2. このフェーズで使う機能

機能役割
pyxel edit ファイル名.pyxresリソースエディタを開く(step0 フェーズ 4 で扱いました)
pyxel.load("ファイル名.pyxres")作った絵をプログラムから読み込む
pyxel.blt(x, y, img, u, v, w, h, colkey)イメージバンクから切り出して画面に貼る
list("文字列")文字列を1 文字ずつのリストにばらす
len(リスト)要素の数を返す。行の数・列の数を知るのに使う
リスト.append(値)リストの末尾に足す。荷物を集めるのに使う
a // b整数の割り算(小数点以下を切り捨てる)

3. 手順

3-1. エディタを開く

step2 フォルダを作ったら、その中で次を実行します。

cd step2
pyxel edit sokoban.pyxres

3-2. タイルを 5 枚描く

イメージバンク 0 のタブを開き、上の配置図のとおりに描きます。 1 段目に 4 枚(床・壁・ゴール・荷物)、2 段目の左端に主人公 1 枚です。 1 枚描くごとに Ctrl + S で保存しておくと安心です。

描くときのコツ
荷物と主人公の輪郭に、黒を使わないでください この 2 つは黒を透明として画面に貼ります。輪郭を黒で描くと、 その線のところだけ下の床が透けて見えます。 輪郭が欲しいときは、色番号 1(濃い紺)など黒以外の暗い色を使ってください。
床・壁・ゴールは透過しないので、黒を使っても構いません。

3-3. 保存して閉じる

Ctrl + S で保存し、Esc でエディタを閉じます。 step2 フォルダに sokoban.pyxres ができていれば完了です。

4. コード

step2/sokoban.py に次を入力してください。

# sokoban.py
# step2 フェーズ1:ステージの地図を読み込んで、画面に並べる

import pyxel

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
TILE = 8            # 1 マスの大きさ(ドット)
HUD_HEIGHT = 12     # 画面の上、手数などを出すために空けておく高さ

# イメージバンク 0 の、切り出し位置
V_TILE = 0          # タイルの段
V_PLAYER = 8        # 主人公の段(コマ A)
U_FLOOR = 0
U_WALL = 8
U_GOAL = 16
U_BOX = 24

# ステージの地図
# # 壁   . 床   O ゴール   $ 荷物   @ 主人公
STAGE1 = [
    "########",
    "#......#",
    "#..O...#",
    "#..$...#",
    "#..@...#",
    "########",
]


class App:
    def __init__(self):
        """起動時の設定"""
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Sokoban")
        pyxel.load("sokoban.pyxres")
        self.load_stage(STAGE1)
        pyxel.run(self.update, self.draw)

    def load_stage(self, rows):
        """文字の地図を読んで、地形・主人公・荷物に分ける"""
        self.tiles = []
        self.boxes = []
        for y in range(len(rows)):
            line = []
            for x in range(len(rows[y])):
                mark = rows[y][x]
                if mark == "@":
                    self.player_x = x
                    self.player_y = y
                    mark = "."          # 主人公の足もとは床
                elif mark == "$":
                    self.boxes.append([x, y])
                    mark = "."          # 荷物の下も床
                line.append(mark)
            self.tiles.append(line)

        # 盤面を画面の中央に置くための、ずらし幅
        board_w = len(self.tiles[0]) * TILE
        board_h = len(self.tiles) * TILE
        self.ox = (SCREEN_WIDTH - board_w) // 2
        self.oy = HUD_HEIGHT + (SCREEN_HEIGHT - HUD_HEIGHT - board_h) // 2

    def update(self):
        """フレーム毎の更新処理(まだ何もしない)"""
        pass

    def draw(self):
        """描画処理"""
        pyxel.cls(pyxel.COLOR_BLACK)

        # 地形を、上の行から 1 マスずつ描く
        for y in range(len(self.tiles)):
            for x in range(len(self.tiles[y])):
                mark = self.tiles[y][x]
                if mark == "#":
                    u = U_WALL
                elif mark == "O":
                    u = U_GOAL
                else:
                    u = U_FLOOR
                pyxel.blt(self.ox + x * TILE, self.oy + y * TILE,
                          0, u, V_TILE, TILE, TILE)

        # 荷物
        for box in self.boxes:
            pyxel.blt(self.ox + box[0] * TILE, self.oy + box[1] * TILE,
                      0, U_BOX, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)

        # 主人公
        pyxel.blt(self.ox + self.player_x * TILE, self.oy + self.player_y * TILE,
                  0, 0, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)


App()

5. 実行する

cd step2
python sokoban.py

黒い画面の中央(やや下)に、8×6 マスの盤面が出れば成功です。 外周が壁、中は床、上のほうにゴールの印、その下に荷物、いちばん下に主人公—— という並びになります。まだ何も動きません。

数字で確かめたいとき この地図なら ox48oy42 になります。 load_stage() の最後に print(self.ox, self.oy) を書き足して実行すると、 ターミナルに 48 42 と出ます。確かめ終わったら消してください。

6. コードの解説

6-1. list() を使わずに、1 文字ずつ足している

文字列をリストにばらす方法として list("#..O") があり、 これは ['#', '.', '.', 'O'] を返します。ところが今回のコードでは使っていません。 ばらしながら、@$ を見つけて取り除く必要があるからです。

            for x in range(len(rows[y])):
                mark = rows[y][x]        # 1 文字取り出す
                if mark == "@":          # 主人公だったら
                    self.player_x = x    #   位置を覚えて
                    self.player_y = y
                    mark = "."           #   床にすり替える
                elif mark == "$":        # 荷物だったら
                    self.boxes.append([x, y])
                    mark = "."
                line.append(mark)        # 1 文字ずつ line に足す

mark = "."入れ物の中身を差し替えてから append しているのがポイントです。 こうすると self.tiles には地形しか残りません

文字列は書き換えられません rows[y]"#..O...#" という文字列です。 文字列は rows[y][x] = "." のように一部だけ書き換えることができませんTypeError になります)。
一方 lineリストなので、line[3] = "." と書き換えられます。 フェーズ 3 で荷物を動かすときにこれが必要になるので、 いま文字列からリストに直しておきます

6-2. 二重ループは「表を、上から 1 行ずつ」

for の中に for が入っている形です。難しく見えますが、 やっていることは紙の表を指でなぞるのと同じです。

for y in range(len(self.tiles)):          # 上の行から順に
    for x in range(len(self.tiles[y])):   #   その行を、左から順に
        mark = self.tiles[y][x]           #     1 マス取り出す
書き方意味この地図での値
len(self.tiles)行の数(縦のマス数)6
len(self.tiles[y])その行の文字数(横のマス数)8
self.tiles[y][x]上から y、左から x のマス'#''.'

外側が y、内側が x です。この順番は逆にしないでください。 self.tiles[y][x] という書き方に合わせておくと、 「先に行を選び、次にその中の列を選ぶ」という読み方が、コードの形とそろいます。

6-3. // は「整数のままの割り算」

Python では 96 / 248.0(小数)になります。 96 // 2 なら 48(整数)です。

ドットの座標に小数が混じっても Pyxel は描いてくれますが、 座標は整数で持っておくほうが、あとの計算で混乱しません。 中央寄せのように「割り切れないこともある」計算では、 // を使って切り捨てると決めておきます。

self.ox = (SCREEN_WIDTH - board_w) // 2      # (160 - 64) // 2 = 48

たとえば盤面が 9 マス(72 ドット)なら (160 - 72) // 2 = 44 です。 左右の余白は 44 と 44 でぴったり。11 マス(88 ドット)なら 36 となり、 右が 1 ドット多くなりますが、見て分かる差ではありません。

6-4. colkey を付けるものと、付けないもの

pyxel.blt(..., 0, u, V_TILE, TILE, TILE)                        # 地形:透過なし
pyxel.blt(..., 0, U_BOX, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)  # 荷物:黒を透過

地形はいちばん下に敷くので、透過する必要がありません。 むしろ透過しないほうが、前に描いたものを確実に塗りつぶせます。

荷物と主人公は地形の上に重ねるので、絵の余白を透過させないと 黒い四角ごと貼りつくことになります。 pyxel.COLOR_BLACK(色番号 0)を透明として指定します。

透過色は、黒でなくてもかまいません blt()呼ぶたびに指定するものなので、絵によって違う色を抜けます。 実際にこのあと、主人公の目を黒く描くために、主人公だけ透過色をグレーに変えましたフェーズ 5備忘録)。 透過色に選ぶのは、その絵の中で背景以外に 1 ドットも使っていない色です。

6-5. 大文字の名前と、self. が付く名前

書き方意味
TILE(大文字) 最後まで変わらない値。ファイルの先頭にまとめて置く TILE, U_WALL, STAGE1
self.ox ゲームの進行につれて変わる値。ステージが変われば作り直される self.tiles, self.player_x, self.boxes
mark, line その場かぎりの値。関数を抜けたら消える x, y, u

self.oxOX という定数にしなかったのは、 ステージごとに盤面の大きさが変わるからです。 フェーズ 6 で 10 ステージに増やすと、この値はステージを切り替えるたびに変わります。

7. うまくいかないときは

症状原因と対処
FileNotFoundError と出る sokoban.pysokoban.pyxres同じフォルダにあるか確認してください。 pyxel.load() の相対パスは、実行するスクリプトの置き場所が基準です
AttributeError: 'App' object has no attribute 'player_x' 地図に @ が 1 つも入っていません。 STAGE1 を見直してください。 主人公がいないと、位置を覚える行が一度も実行されません
盤面がガタガタ/右端が欠ける 地図の行ごとに文字数が違います。中央寄せの計算には self.tiles[0](1 行目)の長さを使っているので、 1 行目だけ長いと右がはみ出します。全部そろえてください
盤面が画面の左上に寄っている blt()self.ox +self.oy + を 足し忘れています
荷物や主人公のまわりに黒い四角が見える blt() の最後の pyxel.COLOR_BLACK が抜けています
主人公や荷物の輪郭が、床のように抜けて見える 絵の輪郭を黒(色番号 0)で描いています。黒は透明として扱われるので、 輪郭には色番号 1 などの黒以外の暗い色を使ってください
床が真っ黒で、盤面の形が分からない 床のタイル(u = 0)を描いていないか、 全面を色番号 0 で塗っています。黒以外の暗い色で塗り直してください
となりのタイルが混ざって表示される 8 ドットの区切りをまたいで描いています。 エディタで、はみ出した部分を消してください
ゴールが見えない ゴールの上に荷物や主人公が乗っていませんか。 いまはそれで正しい動きです。フェーズ 5 で 「ゴールに乗った荷物」の絵に切り替えて、見て分かるようにします

8. 次のフェーズ

盤面が出せました。次は主人公を矢印キーで動かします。 このフェーズで作った self.tilesself.player_xself.player_y が そのまま土台になり、update()pass が中身に置き換わります。

次に出てくる考え方 倉庫番の移動は、step1 の「動かしてから、ぶつかっていないか調べる」とは逆で、 「動かす前に、行き先が壁でないか調べる」です。 あわせて主人公の上・左・右の 3 枚も描き、向いた方向の絵に切り替えます。 step2 概要の土台 2 にその 4 パターンをまとめてあるので、 先に目を通しておくと理解が早くなります。