行き先に荷物があったら、その 1 つ先まで見てから決める
主人公が荷物を押せるようにします。押せるのは 荷物の 1 つ先が空いているときだけ。壁や別の荷物がつかえていたら動きません。 あわせてステージに荷物とゴールをもう 1 組足して、押せない場面も試せるようにします。 フェーズ 2 のコードに 24 行書き足して、できあがりは 156 行になります。
R)はフェーズ 5 で足すので、
それまでは行き詰まったらウィンドウを閉じて起動し直してください。
フェーズ 2 では、行き先の 1 マスだけを見ていました。 荷物が入ると、それでは足りません。行き先に荷物があったとき、 その荷物がどこへ行くのか——つまりもう 1 つ先のマスを見ないと、 押していいかどうか決められないからです。
step2 概要の土台 2 に挙げた 4 パターンを、 そのまま判断の順番に並べ替えると、次の流れになります。
return で表しますself.tiles に入っていない。だから探しに行くフェーズ 1 で、地図を3 つに分けて持つことにしました。 ここでその設計が効いてきます。
| 持っているもの | 中身 | これで答えられること |
|---|---|---|
self.tiles | 地形だけの二次元リスト | そのマスは壁か(tiles[y][x] == "#") |
self.player_x / self.player_y | 主人公のマス目 | 主人公がどこにいるか |
self.boxes | [[3, 3], [5, 3]] のような座標の一覧 |
そのマスに荷物があるか —— 一覧を最後まで見ないと分からない |
壁かどうかは tiles[y][x] で一発で分かります。
ところが荷物は、座標の一覧として持っているので、
「(4, 3) に荷物はある?」と聞かれたら一覧を順に見ていくしかありません。
荷物を動かすといっても、画面の中で何かをつかんで運ぶわけではありません。
やることは self.boxes の中の数字を書き換えるだけです。
そのあと draw() が新しい座標で絵を描くので、動いたように見えます。
新しい Pyxel の機能は出てきません。Python だけです。
| 機能 | 役割 |
|---|---|
return True / return False |
答えを返して終わる。「ある/ない」を答えるメソッドを書くのに使う |
for の中の return |
見つかった時点でループごと抜ける。最後まで回さない |
and | 2 つの条件が両方とも成り立つか(step1 の当たり判定で使いました) |
box[0] += dx | リストの中の数字をその場で書き換える |
3 段階に分けます。今回は最後まで書かないと動きが変わらないので、 3-3 まで書いてから実行してください。
STAGE1 を次のように書き換えます。盤面の大きさは変えません
(8 × 6 のままなので、中央寄せの計算結果も変わりません)。
STAGE1 = [
"########",
"#......#",
"#..O.O.#", # ← ゴールを 1 つ増やす
"#..$.$.#", # ← 荷物を 1 つ増やす
"#..@...#",
"########",
]
try_move() の手前に、メソッドを 2 つ足します。
どちらも self.boxes を先頭から順に見ていくだけの、短いメソッドです。
def has_box(self, x, y):
"""そのマスに荷物があるかどうかを答える"""
for box in self.boxes:
if box[0] == x and box[1] == y:
return True
return False
def move_box(self, x, y, dx, dy):
"""(x, y) にある荷物を、(dx, dy) だけ動かす"""
for box in self.boxes:
if box[0] == x and box[1] == y:
box[0] += dx
box[1] += dy
return
return False の位置に注意してください
return False は for の外側(for と同じ深さ)に置きます。
もし for の中に入れてしまうと、
1 つめの荷物を見た時点で「ない」と答えてしまい、2 つめ以降を見に行きません。try_move() に「押す」を足す壁の判定と、位置を書き換える 2 行のあいだに差し込みます。
if self.tiles[next_y][next_x] == "#": # 行き先が壁なら
return # 何もしないで戻る
if self.has_box(next_x, next_y): # 行き先に荷物があるなら
far_x = next_x + dx # 荷物の、1 つ先のマス
far_y = next_y + dy
if self.tiles[far_y][far_x] == "#": # その先が壁なら押せない
return
if self.has_box(far_x, far_y): # その先に別の荷物があっても押せない
return
self.move_box(next_x, next_y, dx, dy)
self.player_x = next_x
self.player_y = next_y
far_x から move_box() までの 6 行は、
if self.has_box(...) の中です(self.player_x = next_x より 1 段深い)。self.player_x と self.player_y)は外側に置きます。
荷物を押したときも、押さずに進むときも、どちらでも主人公は動くからです。
ここを 1 段深く書いてしまうと、荷物を押したときしか主人公が動かなくなります。
書き足したのは STAGE1 の 2 行と、has_box()・move_box()、
そして try_move() の中ほどだけです。
# sokoban.py
# step2 フェーズ3:荷物を押す
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.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 has_box(self, x, y):
"""そのマスに荷物があるかどうかを答える"""
for box in self.boxes:
if box[0] == x and box[1] == y:
return True
return False
def move_box(self, x, y, dx, dy):
"""(x, y) にある荷物を、(dx, dy) だけ動かす"""
for box in self.boxes:
if box[0] == x and box[1] == y:
box[0] += dx
box[1] += dy
return
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 # 何もしないで戻る
if self.has_box(next_x, next_y): # 行き先に荷物があるなら
far_x = next_x + dx # 荷物の、1 つ先のマス
far_y = next_y + dy
if self.tiles[far_y][far_x] == "#": # その先が壁なら押せない
return
if self.has_box(far_x, far_y): # その先に別の荷物があっても押せない
return
self.move_box(next_x, next_y, dx, dy)
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
盤面にリンゴが 2 つ、ゴールが 2 つ出ます。確かめてほしいのは次の 5 つです。
| やってみること | こうなれば成功 |
|---|---|
| 荷物に向かって進む | 荷物と主人公が、いっしょに 1 マス動く |
| そのまま押し続ける | 荷物が壁の手前まで来たら止まる。主人公も止まる |
| 荷物をゴールに乗せる | 荷物がゴールの印の上に重なる(見た目はまだ変わりません) |
| ゴールから押し出す | ゴールの印がちゃんと残っている |
| 荷物を 2 つ並べて押す | 動かない。手前の荷物も主人公も止まったまま |
← ↑ → と押すと、
左のリンゴが 1 マス右に動いて、2 つのリンゴが隣どうしになります。
そこでもう一度 → を押しても、もう動きません。
倉庫番で荷物をまとめて押せないのは、この判定のおかげです。
try_move() の最後に print(self.boxes) を書き足すと、
動くたびに荷物の座標が出ます。はじめは [[3, 3], [5, 3]] で、
↑ を 1 回押すと [[3, 2], [5, 3]] になります。
もう一度押すと [[3, 1], [5, 3]]、3 回目は変わりません(その先が壁のため)。
確かめ終わったら消してください。
R)はフェーズ 5 で足すので、それまではウィンドウを閉じて起動し直してください。
has_box() は「見つけたら即答、なければ最後に否定」 def has_box(self, x, y):
for box in self.boxes:
if box[0] == x and box[1] == y:
return True # 見つかった。ここで終わり
return False # 全部見たけれど、なかった
return はメソッドをその場で終わらせるので、
for の途中にあってもループごと抜けます。
荷物が 10 個あっても、1 個目で見つかれば残りの 9 個は見に行きません。
そして return False は for の外にあります。
ここに到達したということは、ループが最後まで回りきった——
つまりどの荷物とも一致しなかったということです。
True か False を返す
has_box(4, 3) は「(4, 3) に荷物はある?」という質問で、
答えは True か False です。
だから if self.has_box(next_x, next_y): と、
if にそのまま置けます。== True と書き足す必要はありません。
if self.has_box(...) == True: は間違いではありませんが、
「ある?が本当なら」と二度言っているようなもので、回りくどくなります。
box[0] += dx で self.boxes が変わるのか
move_box() は self.boxes という名前を一度も書き換えていません。
それなのに、荷物の座標が変わります。ここは Python の大事なところなので、
小さな例で確かめてみてください。
>>> kazu = [1, 2, 3]
>>> for n in kazu:
... n += 1
>>> kazu
[1, 2, 3] ← 変わらない
>>> boxes = [[3, 3], [5, 3]]
>>> for box in boxes:
... box[0] += 1
>>> boxes
[[4, 3], [6, 3]] ← 変わる!
同じ形の for なのに、結果が違います。違いは何をしているかです。
| 書き方 | やっていること | 元のリストは |
|---|---|---|
n += 1 |
n という名札を、別の数に付け替えている。
数そのものは書き換えられない |
変わらない |
box[0] += 1 |
box が指しているリストの中身を書き換えている。
名札は付け替えていない |
変わる |
for box in self.boxes: と書いたとき、box は
self.boxes の中のリストそのものを指しています。
写しではありません。だから box[0] を書き換えると、
self.boxes の中身も同時に変わります。
R でやり直す」を作るときにいちばんの落とし穴になります。
backup = self.boxes と書いても控えになりません。
同じリストに名札が 2 枚付くだけで、片方を押すともう片方も動いてしまうからです。
よく見ると、has_box() で見つけた荷物を、
move_box() でもう一度探し直しています。
1 回で済ませたほうが速いのは確かです。
それでもこう書いたのは、2 つのメソッドの役目がはっきり分かれるからです。
has_box() は調べるだけで、盤面を変えません。
move_box() は動かすだけです。混ぜると、
「調べたつもりが動いていた」という分かりにくい不具合を生みます。
そして荷物はせいぜい数個です。2 回探しても、 1 回のキー入力で見る回数は多くて十数回。60 分の 1 秒の中では、無いに等しい手間です。 速さより読みやすさを選んでよい場面だと判断しました。
荷物を self.tiles の中に "$" として置く作りにしていたら、
押すたびに2 か所を書き換えることになっていました。
tiles[3][3] = "." # 荷物がいたマスを、床に戻す
tiles[2][3] = "$" # 新しいマスに、荷物を置く
一見わかりやすそうですが、ゴールの上に乗った荷物で破綻します。
ゴール "O" のマスに荷物を置くと "O" が "$" で上書きされ、
そこから押し出したときに床 "." に戻してしまう——
ゴールが消えるのです。
いまの作りなら、この不具合は起こりようがありません。
self.tiles は最初から最後まで地形だけで、荷物がどこにあっても書き換えないからです。
実行して「ゴールから押し出してもゴールの印が残る」ことを確かめてください。
何もしていないのに正しく動く——それが、設計で不具合を防ぐということです。
far_x / far_y という名前 far_x = next_x + dx # 荷物の、1 つ先のマス
far_y = next_y + dy
next(次の)に対して far(遠い)です。
主人公から見て 2 マス先を指しています。
next_next_x でも間違いではありませんが、長くて読みにくくなります。
この 2 行を if self.has_box(...) の中に置いているのも意図があります。
行き先に荷物があったときにしか、この座標は意味を持たないからです。
使う直前に、使う場所で計算する——こうしておくと、
関係のない変数が増えず、あとで読み返したときに迷いません。
| 症状 | 原因と対処 |
|---|---|
| 荷物をすり抜けてしまう | try_move() に if self.has_box(next_x, next_y): の
ひとかたまりを足し忘れています |
| 主人公だけ動いて、荷物がその場に残る | self.move_box(next_x, next_y, dx, dy) を呼んでいないか、
渡す座標が違います。渡すのは「荷物がいまいるマス」
(far_x ではなく next_x)です |
| 荷物を押したときしか、主人公が動かない | いちばん下の self.player_x = next_x の 2 行が、
if の中に入っています。字下げを 1 段浅くしてください |
| 荷物が壁にめり込む/盤面から出る | if self.tiles[far_y][far_x] == "#": を書いていません。
next ではなく far を見ているか確認してください |
| 荷物どうしが重なる | if self.has_box(far_x, far_y): を書いていません |
| 荷物 2 つが同時に動く | move_box() の中で far_x を使っています。
動かす荷物を探すのは x, y(=next の位置)で、
足すのは dx, dy です |
| 荷物の手前で必ず止まる(押せない) | has_box() の return False が
for の中に入っていませんか。
1 個目を見た時点で答えを返してしまい、
far の判定が常に「荷物あり」になっている可能性もあります |
IndexError: list index out of range |
地図の外周が壁で囲まれていません。 2 マス先を見るようになったぶん、フェーズ 2 より起きやすくなります |
| ゴールに乗せた荷物が、見た目で分からない | いまはそれで正しいです。「ゴールに乗った荷物」の絵に切り替えるのは
フェーズ 5 です(配置図の u = 32 に描く予定の 1 枚) |
| 荷物を角に入れてしまい、詰んだ | 不具合ではありません。倉庫番はそういうゲームです。
いったんウィンドウを閉じて起動し直してください。
R でのやり直しはフェーズ 5 で足します |
これで倉庫番として遊べるようになりました。ただし動きはまだカクッと瞬間移動です。 次は歩くアニメを入れて、隣のマスへすべるように動かします。
v = 16)に描き足すことになります。
1 コマ目とほとんど同じ絵で、足の位置だけ左右を入れ替えるのが手軽です。