フェーズ 4 歩くアニメを入れる

盤面の上ではもう着いている。あとから追いつくのは、絵だけ

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

ゴール

いまはキーを押した瞬間に、主人公がカクッと 8 ドット飛びます。 これを8 フレームかけて、1 ドットずつすべるようにします。 押した荷物もいっしょにすべります。あわせて足踏みを入れ、 矢印キーを押しっぱなしにすると歩き続けるようにします。 フェーズ 3 のコードに 36 行ほど書き足して、できあがりは 192 行になります。

ここが step2 のいちばんの山場です 今回入れる考え方——「盤面の上の位置」と「画面に見えている位置」を分ける——は、 step3 の縦スクロールシューティングでも、そのあとのどのゲームでも使います。 行数のわりに考えることが多いので、3-4 と 3-8 はゆっくり読んでください
先に絵を 4 枚描きます このフェーズは絵の描き足しから始めます3-1)。 コードだけ先に入力しても、足踏みの絵がないと主人公が消えるので気をつけてください。

1. 考え方

1-1. キーを押した瞬間に、盤面の上ではもう着いている

「8 フレームかけて動かす」と聞くと、 1 ドット動かすたびに壁や荷物を調べ直す作りを思い浮かべるかもしれません。 それはとても大変です。ドットの途中ではマスとマスのあいだにいるので、 「いまどのマスにいるのか」がはっきりしません。

倉庫番では、そうしません。キーを押したその瞬間に、判定はぜんぶ終わらせます。 壁か、荷物か、押せるか——フェーズ 3 で書いた try_move() が、 今までどおり一瞬で答えを出し、self.player_xもう新しいマスになります。

そのうえで、絵を描くときだけ「まだ着いていないふり」をします。 これが step2 概要の土台 3 に書いた 「マス目の位置と、目に見えている位置は別もの」です。

右キーを押してから 4 フレーム目のようす 絵は、この 8 ドットのあいだを移動中 x=2 x=3 x=4 x=5 self.player_x = 4 盤面の上の位置。判定はもう終わっている self.from_x = 3 見た目の出発マス。着いたら 4 に合わせる 画面に描くドット位置は、こう出します px = ox + from_x * TILE + (player_x - from_x) * TILE * walk_timer // MOVE_FRAMES px = 48 + 3 * 8 + (4 - 3 ) * 8 * 4 // 8 = 76 walk_timer は 1 フレームに 1 ずつ増える数。8 になったら到着です 到着したら from_x を player_x に合わせ、walk_timer を 0 に戻します
player_xfrom_x別の変数で持ちます。1 つにまとめようとすると、必ず判定がおかしくなります
持ちものが 3 つ増えます
変数意味
self.from_x / self.from_y 見た目の出発マス。止まっているときは player_x と同じ値
self.walk_timer 歩き始めてから何フレーム経ったか0 なら止まっている
self.pushing いまの一歩で荷物を押しているかTrue / False

1-2. 歩いているあいだは、キーを見ない

歩いている 8 フレームのとちゅうで次のキーを受け付けてしまうと、 着く前に次のマスへ移ろうとして、絵の出発点がめちゃくちゃになります。 そこで止まっているときだけキーを見ることにします。

update(毎フレーム呼ばれる) 歩いている途中? walk_timer > 0 いいえ(止まっていた) はい walk_timer を 1 増やす 8 を超えた? = 隣のマスに着いた いいえ まだ途中。 キーは見ない はい walk_timer を 0 に戻し、 from と pushing を整える キーを見る handle_key() 到着したその同じフレームでキーを見に行くのが、なめらかさの決め手です
右上に 2 本の矢印が集まります。「もともと止まっていた」ときも「いま着いた」ときも、行き先は同じ
キーリピートが、ただで手に入ります この作りにすると、矢印キーを押しっぱなしにしたときの歩き続ける速さが、 そのまま「8 フレームに 1 マス」になります。
フェーズ 3 まで使っていた pyxel.btnp() は 「押した瞬間だけ True」でした。これだと押しっぱなしでは 1 歩しか進みません。 今回は pyxel.btn()押されている間ずっと True)に変えます。 歩いている間は見に行かないので、連打しすぎることもありません

1-3. 押した荷物も、同じ 8 フレームで動かす

荷物を押したとき、主人公だけがすべって、荷物は瞬間移動すると台なしです。 押した荷物も、まったく同じ 8 フレームで、同じだけずらして描きます。

上キーで押しているところ(4 フレーム目) リンゴのずれ 4 ドット ワンコのずれ 4 ドット 同じ式・同じ walk_timer なので、必ずそろいます boxes[0] = [3, 2] player = (3, 3) (盤面ではもう着いている) (盤面ではもう着いている) 描く順番は荷物 → 主人公。1 マスあいているので重なりません
すべらせるのは押した 1 つだけ。ほかの荷物は、いつもどおりマス目のまま描きます
「目の前に荷物がある」だけでは足りません 押した荷物は、たしかに主人公の目の前のマスにいます。 でも「目の前に荷物がある」を条件にしてしまうと、 押していないのに前に荷物があるとき——たとえば、少し離れた荷物に向かって 1 歩近づいたとき—— その荷物までいっしょにすべってしまいます。
だから self.pushing という「この一歩で押したかどうか」の覚え書きを持ちます。

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

機能役割
pyxel.btn(キー) 押されている間ずっと Truebtnp(押した瞬間だけ)と入れ替える
// 小数を切り捨てる割り算。ドットは整数なので、こちらを使う
値を返すメソッド slide_pos() のように、計算結果を返すだけのメソッドを自分で作る
ifelse 荷物ごとに「すべらせる/そのまま描く」を切り替える
新しい Pyxel の機能は btn だけです 残りはぜんぶ Python の話です。絵を動かしているのは、blt に渡す座標の計算だけで、 Pyxel 側に「アニメーション機能」があるわけではありません。 ここが分かると、step3 以降でどんな動きでも作れるようになります。

3. 手順

8 段階に分けます。3-2 から 3-8 まで書かないと動かないので、 最後まで書いてから実行してください。

3-1. 足踏みの絵(コマ B)を 4 枚描く

リソースエディタ(pyxel edit sokoban.pyxres)を開き、 イメージバンク 0 の 3 段目(v = 16に、主人公の 4 方向をもう 1 組描きます。 場所は フェーズ 1 の配置図のとおりです。

位置描くもの
u = 0, v = 16下向き・コマ B
u = 8, v = 16上向き・コマ B
u = 16, v = 16左向き・コマ B
u = 24, v = 16右向き・コマ B
ゼロから描かなくて大丈夫です リソースエディタでは、コマ A を範囲選択してコピーし、1 段下に貼り付けられます。 貼り付けたあと、足のドットだけ左右を入れ替えてください。 それだけで歩いて見えます。体や顔は動かさないほうが、チカチカせずに落ち着きます。
2 コマしかないので、ちょっと大げさなくらい足を動かすのがコツです。

3-2. 定数を 2 つ足し、V_PLAYER の名前を変える

ファイルの上のほう、定数を並べているところを書き換えます。 V_PLAYERV_PLAYER_A に名前を変えます (B と並べたときに、どちらがどちらか分かるようにするためです)。

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
TILE = 8                # 1 マスの大きさ(ドット)
HUD_HEIGHT = 12         # 画面の上、手数などを出すために空けておく高さ
MOVE_FRAMES = 8         # 隣のマスへ移りきるまでにかけるフレーム数   ← 足す

# イメージバンク 0 の、切り出し位置
V_TILE = 0              # タイルの段
V_PLAYER_A = 8          # 主人公の段(コマ A)                      ← 名前を変える
V_PLAYER_B = 16         # 主人公の段(コマ B・足を入れ替えた絵)     ← 足す
なぜ 8 フレームなのか 1 マスは 8 ドットです。それを 8 フレームで渡るので、 1 フレームにちょうど 1 ドットずつ進みます。割り切れるので、動きがガタつきません。
Pyxel の標準は 1 秒 30 フレームなので、1 マスはおよそ 0.27 秒。 遅いと感じたら MOVE_FRAMES = 4(1 フレームに 2 ドット)にすると、きびきび歩きます。 あとで好みに合わせて変えてみてください。

3-3. load_stage() に、歩きの状態を足す

最初の 3 行のうしろに 2 行足し、二重ループのあとにも 2 行足します。

    def load_stage(self, rows):
        """文字の地図を読んで、壁・主人公・荷物に分ける"""
        self.tiles = []
        self.boxes = []
        self.player_dir = DIR_DOWN  # はじめは下を向いている
        self.walk_timer = 0         # 0 なら止まっている。1〜8 は歩いている途中
        self.pushing = False        # いまの一歩で、荷物を押しているか
            self.tiles.append(line)

        self.from_x = self.player_x     # 見た目の出発マス
        self.from_y = self.player_y

        # 盤面を画面の中央に置くための、ずらし幅
from_x は、ループのあとに書きます self.player_x は、地図の中の @ を見つけたときに決まります。 つまり二重ループが終わるまで存在しません。 ループより前に self.from_x = self.player_x と書くと、 AttributeError になります。

3-4. update() を書き換える

いまは self.handle_key() の 1 行だけです。ここが今回の心臓部になります。

    def update(self):
        """フレーム毎の更新処理"""
        if self.walk_timer > 0:                 # 歩いている途中
            self.walk_timer += 1
            if self.walk_timer > MOVE_FRAMES:   # 隣のマスに着いた
                self.walk_timer = 0
                self.from_x = self.player_x     # 見た目を、いまいるマスに合わせる
                self.from_y = self.player_y
                self.pushing = False

        if self.walk_timer == 0:                # 止まっているときだけ、キーを見る
            self.handle_key()
2 つめの ifelse ではありません 上の if の中で「着いた」と分かると、その場で walk_timer0 になります。 だから下の if同じフレームで成り立ち、すぐ次の一歩に入れます。
ここを else にすると、着いたフレームはキーを見ずに終わってしまい、 1 マスごとに 1 フレームぶん立ち止まります。押しっぱなしで歩いたときに、 かすかにガクガクします6-1 でくわしく見ます。

3-5. btnpbtn に変える

handle_key() の 4 か所を書き換えるだけです。p を消します。

    def handle_key(self):
        """矢印キーを見て、押された向きへ 1 歩進もうとする"""
        if pyxel.btn(pyxel.KEY_DOWN):
            self.try_move(DIR_DOWN)
        elif pyxel.btn(pyxel.KEY_UP):
            self.try_move(DIR_UP)
        elif pyxel.btn(pyxel.KEY_LEFT):
            self.try_move(DIR_LEFT)
        elif pyxel.btn(pyxel.KEY_RIGHT):
            self.try_move(DIR_RIGHT)

3-6. try_move() の最後に 2 行足す

荷物を動かした直後に 1 行、いちばん下に 1 行です。

            self.move_box(next_x, next_y, dx, dy)
            self.pushing = True                 # この一歩は、荷物を押している

        self.player_x = next_x
        self.player_y = next_y
        self.walk_timer = 1                     # ここから 8 フレームかけて歩く
self.pushing = True の字下げに注意 self.move_box(...)同じ深さ——つまり if self.has_box(next_x, next_y): の中です。
いちばん下の self.walk_timer = 1外側。押しても押さなくても歩くからです。

3-7. slide_pos() を足す

try_move()draw() のあいだに、新しいメソッドを 1 つ足します。 1-1 の式を、そのままコードにしたものです。

    def slide_pos(self, origin, from_cell, to_cell):
        """出発マスから到着マスへ、いまどこまで来たかをドットで返す"""
        moved = (to_cell - from_cell) * TILE * self.walk_timer // MOVE_FRAMES
        return origin + from_cell * TILE + moved
x にも y にも使えます originself.ox を渡せば横の位置、self.oy を渡せば縦の位置です。 「マス目 → ドット」の変換に、途中の分を足しているだけなので、 主人公にも荷物にも同じものが使えます。 同じ計算を 4 か所に書かずにすみます。

3-8. draw() の、荷物と主人公を書き換える

壁を描く二重ループはそのままです。その下の 2 か所を書き換えます。

        # 荷物。押している 1 つだけは、主人公といっしょにすべる
        dx, dy = MOVES[self.player_dir]
        push_x = self.player_x + dx         # 押した荷物は、主人公の目の前にいる
        push_y = self.player_y + dy
        for box in self.boxes:
            if self.pushing and box[0] == push_x and box[1] == push_y:
                bx = self.slide_pos(self.ox, box[0] - dx, box[0])
                by = self.slide_pos(self.oy, box[1] - dy, box[1])
            else:
                bx = self.ox + box[0] * TILE
                by = self.oy + box[1] * TILE
            pyxel.blt(bx, by, 0, U_BOX, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)

        # 主人公
        px = self.slide_pos(self.ox, self.from_x, self.player_x)
        py = self.slide_pos(self.oy, self.from_y, self.player_y)
        v = V_PLAYER_A
        if self.walk_timer >= MOVE_FRAMES // 2: # 歩きの後半は、足を入れ替える
            v = V_PLAYER_B
        pyxel.blt(px, py, 0, self.player_dir * TILE, v, TILE, TILE, pyxel.COLOR_BLACK)
押した荷物の「出発マス」は、計算で出せます 荷物はいま box[0] にいて、dx だけ動いたところです。 ということは、出発マスは box[0] - dx。 荷物ごとに出発マスを覚えておく必要はありません。

4. できあがりのコード

書き足したのは定数 2 つ、load_stage() の 4 行、update() の中身、 try_move() の 2 行、新しい slide_pos()、 そして draw() の下半分です。

# sokoban.py
# step2 フェーズ4:歩くアニメを入れる

import pyxel

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
TILE = 8                # 1 マスの大きさ(ドット)
HUD_HEIGHT = 12         # 画面の上、手数などを出すために空けておく高さ
MOVE_FRAMES = 8         # 隣のマスへ移りきるまでにかけるフレーム数

# イメージバンク 0 の、切り出し位置
V_TILE = 0              # タイルの段
V_PLAYER_A = 8          # 主人公の段(コマ A)
V_PLAYER_B = 16         # 主人公の段(コマ B・足を入れ替えた絵)
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  # はじめは下を向いている
        self.walk_timer = 0         # 0 なら止まっている。1〜8 は歩いている途中
        self.pushing = False        # いまの一歩で、荷物を押しているか

        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)

        self.from_x = self.player_x     # 見た目の出発マス
        self.from_y = self.player_y

        # 盤面を画面の中央に置くための、ずらし幅
        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):
        """フレーム毎の更新処理"""
        if self.walk_timer > 0:                 # 歩いている途中
            self.walk_timer += 1
            if self.walk_timer > MOVE_FRAMES:   # 隣のマスに着いた
                self.walk_timer = 0
                self.from_x = self.player_x     # 見た目を、いまいるマスに合わせる
                self.from_y = self.player_y
                self.pushing = False

        if self.walk_timer == 0:                # 止まっているときだけ、キーを見る
            self.handle_key()

    def handle_key(self):
        """矢印キーを見て、押された向きへ 1 歩進もうとする"""
        if pyxel.btn(pyxel.KEY_DOWN):
            self.try_move(DIR_DOWN)
        elif pyxel.btn(pyxel.KEY_UP):
            self.try_move(DIR_UP)
        elif pyxel.btn(pyxel.KEY_LEFT):
            self.try_move(DIR_LEFT)
        elif pyxel.btn(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.pushing = True                 # この一歩は、荷物を押している

        self.player_x = next_x
        self.player_y = next_y
        self.walk_timer = 1                     # ここから 8 フレームかけて歩く

    def slide_pos(self, origin, from_cell, to_cell):
        """出発マスから到着マスへ、いまどこまで来たかをドットで返す"""
        moved = (to_cell - from_cell) * TILE * self.walk_timer // MOVE_FRAMES
        return origin + from_cell * TILE + moved

    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)

        # 荷物。押している 1 つだけは、主人公といっしょにすべる
        dx, dy = MOVES[self.player_dir]
        push_x = self.player_x + dx         # 押した荷物は、主人公の目の前にいる
        push_y = self.player_y + dy
        for box in self.boxes:
            if self.pushing and box[0] == push_x and box[1] == push_y:
                bx = self.slide_pos(self.ox, box[0] - dx, box[0])
                by = self.slide_pos(self.oy, box[1] - dy, box[1])
            else:
                bx = self.ox + box[0] * TILE
                by = self.oy + box[1] * TILE
            pyxel.blt(bx, by, 0, U_BOX, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)

        # 主人公
        px = self.slide_pos(self.ox, self.from_x, self.player_x)
        py = self.slide_pos(self.oy, self.from_y, self.player_y)
        v = V_PLAYER_A
        if self.walk_timer >= MOVE_FRAMES // 2: # 歩きの後半は、足を入れ替える
            v = V_PLAYER_B
        pyxel.blt(px, py, 0, self.player_dir * TILE, v, TILE, TILE, pyxel.COLOR_BLACK)

App()

5. 実行する

cd step2
python sokoban.py

見た目のマス配置はフェーズ 3 と同じです。確かめてほしいのは、動きのほうです。

やってみることこうなれば成功
矢印キーを 1 回だけ押す カクッと飛ばず、すーっと 1 マスすべる。途中で足が入れ替わる
矢印キーを押しっぱなしにする とことこ歩き続ける。止まらずになめらかにつながる
荷物を押す ワンコとリンゴが、ぴったりそろってすべる。離れたり重なったりしない
壁に向かって押しっぱなしにする その場で向きだけ変わる。足踏みはしない(歩いていないので)
少し離れた荷物に向かって歩く 荷物はじっとしている。ワンコだけが近づいていく
歩いている途中で、別の矢印キーを押す 着くまで無視される。着いてから、その向きへ歩き出す
速さを変えて、手ざわりを比べてみてください MOVE_FRAMES4 にすると 2 倍の速さ、16 にすると半分の速さになります。 倉庫番は「押しまちがえたくない」ゲームなので、少しゆっくりめのほうが落ち着いて遊べます。 実際に指で押してみて、自分がいちばん気持ちいい数字を選んでください。
なお 8 で割り切れない数(5 や 6 など)でも動きますが、 1 フレームあたりのドット数が そろわないので、よく見るとカクつきます。2 / 4 / 8 / 16 がおすすめです。
主人公が消えたときは コマ B(v = 16)を描いていません。 イメージバンクの何もない場所を切り出すと、透明色(黒)だけが並ぶので、 歩いている後半だけ姿が消えます。3-1 に戻って 4 枚描いてください。

6. コードの解説

6-1. なぜ else ではなく、2 つめの if なのか

        if self.walk_timer > 0:
            ...
            if self.walk_timer > MOVE_FRAMES:
                self.walk_timer = 0        # ← ここで 0 になる

        if self.walk_timer == 0:           # ← だから、同じフレームで成り立つ
            self.handle_key()

矢印キーを押しっぱなしにしているときの、フレームごとの動きを追ってみます。 8 フレーム目に着いて、その同じフレームで次の一歩が始まります

フレームwalk_timer画面のワンココマ
11出発マスから 1 ドットA
2 〜 32 〜 32 〜 3 ドットA
4 〜 74 〜 74 〜 7 ドットB
888 ドット=到着B
99 → 0 → 1次のマスへ 1 ドットA

9 フレーム目に注目してください。walk_timer9 になって「着いた」と分かり、 0 に戻され、そのまま handle_key() が呼ばれて 1 になります。 1 フレームのあいだに 3 回書き換わっています。おかげで、 ワンコは 1 フレームに 1 ドットずつ、休まずに進み続けます。

もし else にすると、9 フレーム目は「着いた」で終わってしまい、 キーを見るのは 10 フレーム目です。9 フレーム目と 10 フレーム目で、同じ場所に立ち止まります。 1 マスごとに 1 回、わずかに引っかかる歩き方になります。

この 1 フレームは、本当に見えるのか 1 フレームは 30 分の 1 秒、およそ 0.03 秒です。単発では気づけません。 でも1 マスごとに必ず起きるので、押しっぱなしで歩くと 「なめらか・カク・なめらか・カク」のくり返しになり、これは分かります
気になったら、両方書いて見比べてみてください。 こういう「1 フレームの差」に気づけるようになると、ゲームの手ざわりが一段よくなります。

6-2. // は、切り捨ての割り算

        moved = (to_cell - from_cell) * TILE * self.walk_timer // MOVE_FRAMES

/ だと 4.0 のような小数になります。 Pyxel の blt() に小数を渡すと、思わぬ位置に描かれたりエラーになったりします。 //小数点以下を切り捨てて整数を返すので、ドットの計算にはこちらを使います。

>>> 8 * 3 / 8
3.0
>>> 8 * 3 // 8
3
>>> 8 * 3 // 5      # 割り切れないとき
4
かけ算を先にするのが大事です TILE * walk_timer // MOVE_FRAMES は、 先にかけてから割ります8 * 3 // 83)。
もし TILE * (walk_timer // MOVE_FRAMES) と書くと、 3 // 8 が先に 0 になってしまい、 7 フレームのあいだ 1 ドットも動かず、最後に 8 ドット飛びます。 かっこをつけないほうが正しい、めずらしい例です。

6-3. btnbtnp の違い

いつ True になるか押しっぱなしにすると
pyxel.btnp(キー)押した瞬間の 1 フレームだけ 何も起きない(1 歩で止まる)
pyxel.btn(キー)押されているあいだ、ずっと 毎フレーム True

フェーズ 2・3 では、瞬間移動だったので btnp がちょうどよいブレーキになっていました。 btn にしていたら、1 秒で 30 マス進んでしまいます。

いまは「歩いているあいだはキーを見ない」というブレーキを、自分で作りました。 だから btn に変えても暴走しません。 むしろ btnp のままだと、押しっぱなしで歩き続けられず、 1 マスごとにキーを押し直すことになります。

btnp にはリピート機能もあります pyxel.btnp(キー, 12, 4) のように書くと、 「12 フレーム押しっぱなしにしたら、そこから 4 フレームおきに True」になります。 これでもキーリピートは作れます。
ただし歩くアニメとは別の時計で動くので、ずれてチカチカしがちです。 今回のようにアニメの区切りがそのままリピートの間隔になる作りのほうが、きれいにそろいます。

6-4. from_x は「見た目の出発点」

self.from_x は、止まっているあいだは self.player_x と同じ値です。 だから slide_pos() の計算はこうなります。

moved  = (4 - 4) * 8 * 0 // 8   →  0
return   48 + 4 * 8 + 0          →  80      # マス目どおりの位置

つまり止まっているときも、同じ式で正しい位置が出ます。 「歩いているとき用」と「止まっているとき用」で描き分ける必要はありません。 if が 1 つ減ります。

到着したときに from を合わせ忘れると self.from_x = self.player_x を書き忘れると、 次の一歩で、前々回いた場所からすべり始めます。 歩くたびに 1 マスぶんずつ、絵が後ろへ取り残されていきます。

6-5. self.pushing がないと、どうなるか

「主人公の目の前に荷物がある」だけを条件にしてしまうと、こうなります。

#..@.$.#      ← ワンコと、2 マス先のリンゴ

→ を 1 回押す(押していない。ただ近づいただけ)
#...@$.#      ← 盤面はこう

でも画面では、リンゴまで 8 ドットすべってしまう

リンゴがワンコの上に重なりながらついてきます。 self.pushing は「この一歩で本当に押したか」を覚えておくための、 True / False ひとつだけの小さな覚え書きです。

後始末を忘れずに self.pushing = True にしたら、着いたときに False に戻しますupdate() の中でやっています)。 戻し忘れると、次に止まっているあいだじゅう 目の前の荷物が 1 マス手前に描かれ続けます
「立てたフラグは、どこで下ろすか」までをセットで考える—— これはこの先ずっと使う習慣です。

6-6. 足踏みは、たった 2 コマ

        v = V_PLAYER_A
        if self.walk_timer >= MOVE_FRAMES // 2: # 歩きの後半は、足を入れ替える
            v = V_PLAYER_B

MOVE_FRAMES // 24 です。つまり 前半(1〜3)はコマ A、後半(4〜8)はコマ B、 着いたら walk_timer0 になるのでコマ A に戻ります。 1 マス歩くたびに A → B → A と、足が 1 往復します。

V_PLAYER_AV_PLAYER_B は、 イメージバンクの切り出す段(縦の位置)です。横の位置 (self.player_dir * TILE)はそのままなので、 向きと足踏みが、きれいに掛け算になっています。4 方向 × 2 コマ = 8 枚の絵を、 数字 2 つで選び分けているわけです。

3 コマ以上にしたくなったら vif で選ぶかわりに、 コマを並べたリストから選ぶ形にすると増やしやすくなります。 たとえば v = 24 にコマ C を描き足したうえで、こう書きます。
WALK_STEPS = [8, 16, 24, 16]     # 使いたい段を、見せたい順に並べる
v = WALK_STEPS[self.walk_timer * len(WALK_STEPS) // (MOVE_FRAMES + 1)]
いまは 2 コマなので if のほうが読みやすく、この書き方は使いません。 「増えてきたらリストにする」と覚えておいてください。

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

症状原因と対処
まったく動かない/1 歩も進まない try_move() の最後の self.walk_timer = 1 を書いていません。 0 のままだと、絵は動きますが……そもそも歩き出しません
1 歩動いたきり、二度と動かない update() の中で self.walk_timer = 0 に戻していません。 ずっと「歩いている途中」のままなので、キーを見に行けなくなっています
相変わらずカクッと飛ぶ draw() がまだ self.ox + self.player_x * TILE のままです。 self.slide_pos(...) に置き換えてください
歩くたびに、絵がどんどん後ろへ取り残される 到着したときの self.from_x = self.player_x の 2 行を書いていません (6-4 参照)
7 フレーム止まって、最後に 8 ドット飛ぶ slide_pos()TILE * (walk_timer // MOVE_FRAMES) と、 かっこをつけていませんか。かっこは不要です
歩いている後半だけ、主人公が消える コマ B(v = 16)を描いていません。3-1 に戻ってください
足踏みしない(ずっとコマ A) V_PLAYER_B = 168 のまま書いていないか、 if self.walk_timer >= MOVE_FRAMES // 2: を書き忘れています
押していない荷物までついてくる self.pushing を条件に入れていません。 if self.pushing and box[0] == push_x ...self.pushing and が必要です
止まっているのに、目の前の荷物が 1 マス手前にずれて見える 到着時に self.pushing = False に戻していません
押した荷物だけ瞬間移動する 荷物の if の中で slide_pos() を使っていない(else 側と同じ式になっている)か、 出発マスを box[0] のまま渡しています。box[0] - dx です
矢印キーを押しっぱなしにしても 1 歩しか進まない handle_key()btnp のままです。p を消してください
ものすごい速さで走り抜ける btn に変えたのに、update()if self.walk_timer == 0: を書いていません。毎フレーム 1 マス進んでいます
AttributeError: 'App' object has no attribute 'player_x' self.from_x = self.player_x を、 二重ループより前に書いています。ループのあとに移してください
NameError: name 'V_PLAYER' is not defined 定数を V_PLAYER_A に変えたのに、draw() の中が V_PLAYER のままです
斜めに動いてしまう slide_pos()origin に、 縦の計算でも self.ox を渡しています。縦は self.oy です

8. 次のフェーズ

これで「遊べる」から「気持ちよく遊べる」に一歩近づきました。 次はクリア判定と、やり直しです。 荷物がぜんぶゴールに乗ったらクリア、R キーでステージの最初から——を作ります。 これでようやく、行き詰まってもウィンドウを閉じなくてよくなります。

次に出てくる考え方 all():リストの中身がぜんぶ条件を満たすかをひとことで調べる関数です。
リストのコピー:やり直しは load_stage(STAGE1) を呼び直すだけ……に見えますが、 ここに Python の有名な落とし穴があります。 フェーズ 3 の 6-2 で見た 「リストは中身が共有される」の話が、今度は困る側で出てきます。
絵も 1 枚だけ描き足します。ゴールに乗った荷物配置図u = 32, v = 0)——リンゴが少し光っている、といった絵にすると、 乗っているかどうかが一目で分かります。