動かす前に行き先を調べて、壁なら進まない。向いた方向の絵に切り替える
矢印キーで主人公が1 マスずつ動くようにします。壁の方向を押しても進みません。 そして向いている方向の絵に切り替わります。 フェーズ 1 のコードに 38 行書き足して、できあがりは 132 行になります。
step1 では、まず動かして、画面の外に出ていたら max と min で
あとから押し戻していました。ドット単位でぬるぬる動くゲームでは、それでうまくいきます。
倉庫番はマス単位です。1 マスは 8 ドットあるので、 壁にめり込んでから戻すとガタッと見えてしまいます。 それに、このあと荷物を押すようになると 「動かしてから戻す」では荷物まで巻き戻さなければならず、話がややこしくなります。
そこで倉庫番では順番を逆にします。 行き先のマスを先に計算して、そこが壁かどうか調べてから、動かすかどうかを決めます。
try_move() というメソッド 1 つになります
矢印キーは 4 つあります。押されたキーごとに
「x を 1 増やす」「y を 1 減らす」……と書いていくと、
同じような行が 4 つ並びます。フェーズ 3 で荷物を押すようになると、
その 4 つがもう一組必要になり、8 か所を書き分けることになります。
そこで向きに番号を振ります。フェーズ 1 で 下 0・上 1・左 2・右 3 と決めておいた、あの順番です。 番号が決まっていれば、進む先の計算も、絵の切り出し位置も、同じ番号 1 つで引けます。
MOVES は、かっこの組をリストに並べたものです。
step1 フェーズ 9 の CORNERS(お化けが出てくる 4 隅)とまったく同じ形で、
MOVES[2] と番号で引くと (-1, 0) が返ります。self.tiles[y][x] で調べる
フェーズ 1 で作った self.tiles は、地形だけが入った二次元リストです。
調べたいマスの文字が "#" なら壁、それ以外("." か "O")なら通れます。
気を付けるのはかっこの順番です。self.tiles は行のリストなので、
先に行(y)を選び、あとから列(x)を選びます。
tiles[x][y] と書くと、盤面の縦横が入れ替わったところを見てしまいます。
self.tiles[4][3] と書きます| 機能 | 役割 |
|---|---|
pyxel.btnp(キー) | 押した瞬間の 1 フレームだけ True になる。step1 フェーズ 2 で扱いました |
pyxel.KEY_DOWN ほか 3 つ | 矢印キー。KEY_UP / KEY_LEFT / KEY_RIGHT |
(0, 1) のようなタプル | あとから書き換えない、値の組 |
dx, dy = MOVES[n] | 組を2 つの変数へ一度に受け取る(step1 の best_x, best_y = CORNERS[0] と同じ形) |
return(値なし) | メソッドをそこで終わらせる。「何もしない」を書くのに使う |
if … elif | どれか1 つだけを選ぶ。「1 フレームに 1 方向だけ」にするために使う |
フェーズ 1 の sokoban.py に書き足していきます。3 段階に分けて進めます。
定数の並びのいちばん下、STAGE1 の手前に足します。
U_BOX = 24
# 向き。この番号が、そのまま主人公の絵の切り出し位置になる
DIR_DOWN = 0
DIR_UP = 1
DIR_LEFT = 2
DIR_RIGHT = 3
# 向きごとの、1 歩ぶんのずれ(x のずれ, y のずれ)
MOVES = [
(0, 1), # 下
(0, -1), # 上
(-1, 0), # 左
(1, 0), # 右
]
次に、はじめの向きを覚えさせます。
load_stage() の self.boxes = [] の下に 1 行足してください。
def load_stage(self, rows):
"""文字の地図を読んで、地形・主人公・荷物に分ける"""
self.tiles = []
self.boxes = []
self.player_dir = DIR_DOWN # ← 足す:はじめは下を向いている
MOVES の順番を変えてはいけないのか
MOVES の並びは 下・上・左・右で、
DIR_DOWN が 0、DIR_UP が 1……という番号とそろえてあります。
ここを入れ替えると、右を押したのに左へ進むといったことが起きます。
絵の並びとも連動しているので、3 か所(番号・MOVES・絵)を必ずそろえてください。
update() の pass を消して、メソッドを 2 つ足します。
update() のすぐ下に続けて書いてください。
def update(self):
"""フレーム毎の更新処理"""
self.handle_key()
def handle_key(self):
"""矢印キーを見て、押された向きへ 1 歩進もうとする"""
if pyxel.btnp(pyxel.KEY_DOWN):
self.try_move(DIR_DOWN)
elif pyxel.btnp(pyxel.KEY_UP):
self.try_move(DIR_UP)
elif pyxel.btnp(pyxel.KEY_LEFT):
self.try_move(DIR_LEFT)
elif pyxel.btnp(pyxel.KEY_RIGHT):
self.try_move(DIR_RIGHT)
def try_move(self, direction):
"""その向きへ動けるか調べて、動けるときだけ 1 マス進む"""
self.player_dir = direction # 動けなくても、向きだけは変える
dx, dy = MOVES[direction]
next_x = self.player_x + dx
next_y = self.player_y + dy
if self.tiles[next_y][next_x] == "#": # 行き先が壁なら
return # 何もしないで戻る
self.player_x = next_x
self.player_y = next_y
ここで一度実行してください。矢印キーで主人公が 1 マスずつ動き、壁で止まれば成功です。 まだ絵は下向きのままで、荷物はすり抜けます。
draw() のいちばん下、主人公を描いている blt() の
0, のところを self.player_dir * TILE, に書き換えます。
# 主人公(向いている方向の絵)
pyxel.blt(self.ox + self.player_x * TILE, self.oy + self.player_y * TILE,
0, self.player_dir * TILE, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)
0, 0, V_PLAYER, ... と 0 が 2 つ並んでいました。
1 つめの 0 はイメージバンクの番号(そのまま)、
2 つめの 0 が切り出し位置 u です。書き換えるのは2 つめです。
# sokoban.py
# step2 フェーズ2:主人公を矢印キーで動かす
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
# 向き。この番号が、そのまま主人公の絵の切り出し位置になる
DIR_DOWN = 0
DIR_UP = 1
DIR_LEFT = 2
DIR_RIGHT = 3
# 向きごとの、1 歩ぶんのずれ(x のずれ, y のずれ)
MOVES = [
(0, 1), # 下
(0, -1), # 上
(-1, 0), # 左
(1, 0), # 右
]
# ステージの地図
# # 壁 . 床 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 = []
self.player_dir = DIR_DOWN # はじめは下を向いている
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):
"""フレーム毎の更新処理"""
self.handle_key()
def handle_key(self):
"""矢印キーを見て、押された向きへ 1 歩進もうとする"""
if pyxel.btnp(pyxel.KEY_DOWN):
self.try_move(DIR_DOWN)
elif pyxel.btnp(pyxel.KEY_UP):
self.try_move(DIR_UP)
elif pyxel.btnp(pyxel.KEY_LEFT):
self.try_move(DIR_LEFT)
elif pyxel.btnp(pyxel.KEY_RIGHT):
self.try_move(DIR_RIGHT)
def try_move(self, direction):
"""その向きへ動けるか調べて、動けるときだけ 1 マス進む"""
self.player_dir = direction # 動けなくても、向きだけは変える
dx, dy = MOVES[direction]
next_x = self.player_x + dx
next_y = self.player_y + dy
if self.tiles[next_y][next_x] == "#": # 行き先が壁なら
return # 何もしないで戻る
self.player_x = next_x
self.player_y = next_y
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, self.player_dir * TILE, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)
App()
cd step2
python sokoban.py
確かめてほしいのは、次の 4 つです。
| やってみること | こうなれば成功 |
|---|---|
| 矢印キーを1 回ずつ押す | 主人公が1 マスずつ動く |
| 壁に向かって押す | 動かないが、向きだけ変わる |
| 4 方向すべてに向く | 下・上・左・右の絵が正しく切り替わる |
| 矢印キーを押しっぱなしにする | 1 マスだけ動いて止まる(これで正しい) |
btnp() は押した瞬間の 1 フレームだけ True になるので、
押しっぱなしでは 1 歩しか進みません。pyxel.btnp(キー, 12, 4) のように数を 2 つ足すと
「12 フレーム押しっぱなしにしたら、以降 4 フレームごとに繰り返す」という動きにできます。
ただしフェーズ 4 で歩くアニメを入れるときに、この仕組みごと btn() に作り替えます。
ここでは足さずに進んでください。
try_move() の最後に print(self.player_x, self.player_y, self.player_dir)
を書き足すと、動くたびにターミナルへマス目の位置と向きが出ます。
はじめの位置は 3 4 0 です。右を 3 回押すと 6 4 3 になり、
4 回目は壁なので何も出ません(return で先に戻っているためです)。
確かめ終わったら消してください。
if を 4 つ並べず、elif でつなぐ
step1 の move_boy() では、if を4 つ並べていました。
上と右を同時に押したら斜めに動いてほしかったからです。
倉庫番は逆です。1 回の入力で動くのは、1 マスだけ。 斜めには進めませんし、上と右を同時に押したときに 2 マスぶん動いてしまっては困ります。
if pyxel.btnp(pyxel.KEY_DOWN):
self.try_move(DIR_DOWN)
elif pyxel.btnp(pyxel.KEY_UP): # ← elif なので、下が成立したら見に来ない
self.try_move(DIR_UP)
elif でつなぐと、上から順に見ていって、最初に当てはまった 1 つだけが実行されます。
だから同時押しでも、動くのは 1 方向だけです
(この書き方では下がいちばん優先されます。倉庫番では困らないので、この順のままにします)。
dx, dy = MOVES[direction] —— 組を 2 つの変数で受け取る
MOVES[direction] は (0, -1) のような2 つ組を返します。
左辺にカンマで 2 つ変数を並べると、順番どおりに 1 つずつ入ります。
dx, dy = MOVES[1] # MOVES[1] は (0, -1)
# → dx に 0、dy に -1 が入る
step1 フェーズ 9 の best_x, best_y = CORNERS[0] と同じ書き方です。
1 行で済むうえ、MOVES[direction][0] のように
かっこを二重に書かなくてよいので読みやすくなります。
dx と dy という名前について
d は「difference(差)」の頭文字で、
「どれだけ変化するか」を表す変数によく使われる名前です。
ゲームのコードでは dx・dy がほぼ決まり文句なので、
ここでもその慣習に合わせています。
move_x・move_y と書いても動きは同じです。
return だけを書いて「何もしない」を表す if self.tiles[next_y][next_x] == "#":
return
self.player_x = next_x
self.player_y = next_y
return はそこでメソッドを終わらせる合図です。
値を書かずに return とだけ置くと、「ここで打ち切り」という意味になります。
壁だったときは位置を書き換えないまま終わるので、主人公はその場に留まります。
else: を使って書くこともできますが、return のほうが字下げが浅く済みます。
フェーズ 3 で「荷物があるか」「その先はどうか」と調べる条件が増えると、
この差がはっきり効いてきます。step1 の check_ghosts() でも同じ書き方をしました。
handle_key() と try_move() に分けたのか1 つのメソッドにまとめて書くこともできます。分けたのは、 2 つが答えている問いが違うからです。
| メソッド | 答えている問い | この先どう変わるか |
|---|---|---|
handle_key() | どの向きが指示されたか | フェーズ 10 でスマホの十字キーにも対応します。変わるのはここだけです |
try_move() | その向きへ動けるか、動くとどうなるか | フェーズ 3 で荷物を押す処理が入り、フェーズ 8 で手数を数える行が入ります |
分けておくと、あとで片方だけを直せば済みます。
「キーボードでもスマホでも、指示された向きは try_move() に渡すだけ」という形にしておくと、
入力の種類が増えても移動のルールには手を触れずに済みます。
self.player_dir * TILE で切り出し位置を求める pyxel.blt(self.ox + self.player_x * TILE, self.oy + self.player_y * TILE,
0, self.player_dir * TILE, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)
主人公の絵は横に 8 ドットずつ並べて描いてあるので、 向きの番号に 8 を掛ければ、そのまま切り出し位置になります。
下(0) → 0 * 8 = 0
上(1) → 1 * 8 = 8
左(2) → 2 * 8 = 16
右(3) → 3 * 8 = 24
8 と直接書かずに TILE を使っているのは、
この 8 が「絵 1 枚の幅」だからです。
self.player_x * TILE(マス目をドットに直す)と同じ理由の 8 です。
このように意味が同じ数は、同じ名前で書いておくと、
あとで数を変えたくなったときに直す場所が 1 か所で済みます。
| 症状 | 原因と対処 |
|---|---|
| キーを押しても何も起きない | update() に pass が残っていませんか。
self.handle_key() に書き換わっているか確認してください |
AttributeError: 'App' object has no attribute 'player_dir' |
load_stage() に self.player_dir = DIR_DOWN を足し忘れています |
TypeError: try_move() takes 1 positional argument but 2 were given |
def try_move(self, direction): の self, が抜けています。
メソッドの引数は必ず self から始めます |
| 押した向きと逆に動く/上下と左右が入れ替わる | MOVES の並びが違います。上から
(0, 1)・(0, -1)・(-1, 0)・(1, 0) の順です。
組の中身は「x のずれ, y のずれ」の順である点にも注意してください |
| 壁をすり抜ける | self.tiles[next_x][next_y] と順番が逆になっていませんか。
y が先、x があとです |
IndexError: list index out of range |
地図の外周が壁で囲まれていません。盤面の端から出ようとして、
リストの外を見に行っています。倉庫番の地図は必ず外周をすべて # にする
決まりにしておくと、範囲を調べる処理を書かずに済みます |
| 絵が下向きのまま変わらない | blt() の2 つめの 0 を self.player_dir * TILE に
書き換えたか確認してください(1 つめはイメージバンク番号なので 0 のままです) |
| 向きを変えると荷物やゴールの絵が出る | V_PLAYER のところが V_TILE(または 0)になっています。
主人公は2 段目(v = 8)から切り出します |
| ある向きだけ絵が真っ黒/崩れている | リソースエディタの 2 段目(v = 8)に 4 枚そろっていません。 左から 下・上・左・右の順に、8 ドット刻みで並べてください |
| 1 回押すと 2 マス動く | btnp が btn になっていませんか。
btn は押している間ずっと True なので、毎フレーム動いてしまいます |
| 荷物のマスに入り込める | いまはそれで正しい動きです。フェーズ 3 で荷物を押す処理を足します |
| ゴールのマスに乗ると、ゴールの印が消える | 主人公が上に描かれているだけで、地形は消えていません。正しい動きです |
主人公が動くようになりました。次は荷物を押します。 考え方は同じで、調べる範囲が 1 マス先から 2 マス先に広がるだけです。
try_move() に足します。self.tiles ではなく self.boxes(座標のリスト)で持っているので、
「そのマスに荷物があるか」を答える小さなメソッドを 1 つ作ることになります。