パズルLESSON 02グリッド × 遅延パズル原作003

一歩貸してくださいの作り方

いまの一歩が、次の影を動かす。
自分が1歩動くたびに、影は「ひとつ前に出した命令」で動きます。命令を覚えておく仕組みと「1手戻す」機能を作りながら、順番が大切な処理を学びましょう。

完成版の遊び方自分と、1手遅れで同じ命令をまねする影を、壁をうまく使ってそれぞれ別の場所へ導きます。

一歩貸してくださいの元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:矢印キーかWASDで移動、Spaceでその場で待つ、Zで1手戻す、Rでやり直し、Tabで部屋を選ぶ、Escで終了

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

ひとつ前の入力を記憶する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 自分が動く

今回の入力:右
player + R

2. 影が動く

前の記憶:待つ
echo + W

3. 記憶を保存

次の影の命令:右
memory = R

最初の1手を例に、処理の順番を表しています。最初の記憶は「待つ」です。もし影に今回の命令をそのまま渡すと、影が自分と同時に動いてしまい、1手遅れになりません。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:一歩 / Space:待つ / Z:戻す / R:やり直し
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_grid.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

壁のあるマス目を歩く

画面上の位置(ピクセル)と、マス目の位置を分ける。

ROWS は文字で書いた地図で、# が壁、それ以外が床です。State.player はピクセルではなく、(列, 行) というマス目の位置を持ちます。move は、移動先が床なら進んだ位置を、壁なら元の位置を返します。マスの大きさ TILE を掛けてピクセルに直すのは、絵を描くときだけです。btnp はキーを押した瞬間だけ反応するので、1回押すと1マスだけ進みます。P は開始位置です。この段階では E・S・G もただの床として扱います。

下のコードは 01_grid.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_grid.py / 注目する部分
def move(position, command):
    dx, dy = DIRECTIONS[command]
    x, y = position[0] + dx, position[1] + dy
    if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#":
        return x, y
    return position

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_grid.py
macOS / Linux
.venv/bin/python 01_grid.py
画面で見ること青い四角が、矢印キーで1マスずつ動きます。押しっぱなしにしても続けては歩きません。壁に向かって押すと位置はそのままですが、手数は増えます。SPACE で「待つ」のも1手です。
  • 1回押すと1マス
  • 壁から外へ出ない
  • 壁でも手数は増える
つまずきやすいところROWS[x][y] と書くと、列と行が逆になります。文字の地図は「行→列」の順に読むので、ROWS[y][x] が正解です。座標は (x, y) の順にそろえて書きます。
少し変えてみよう

TILE の 24 を変えると、マス目の位置はそのままで、見た目の大きさだけが変わります。なぜそうなるか考えてみましょう(実際に変えるときは、ウィンドウの大きさや文字の位置も合わせて直します)。まずは、壁に向かって1手進んだらどうなるかを紙に書いてみましょう。

01_grid.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(110行)
01_grid.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""一歩貸してください・教材 1/5。原作1部屋目の仕組みを段階化。
実行: python 01_grid.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass

ROWS = ("#######", "#P..G.#", "#.....#", "#E.S..#", "#######")
DIRECTIONS = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0), "W": (0, 0)}
TILE = 24


def find(char):
    return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char)


def move(position, command):
    dx, dy = DIRECTIONS[command]
    x, y = position[0] + dx, position[1] + dy
    if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#":
        return x, y
    return position


@dataclass(frozen=True)
class State:
    player: tuple = find("P")
    turns: int = 0


def step(state, command):
    if command not in DIRECTIONS:
        return state
    player = move(state.player, command)
    return State(player=player, turns=state.turns + 1)


def main(headless=False, screenshot=None):
    import pyxel

    state = State()
    keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"),
            (pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_SPACE, "W"))

    def update():
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        nonlocal state
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                state = step(state, command)
                break  # 同じフレームに複数の一手を進めない。

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 8, "ECHO STEPS / STEP 1", 10)
        pyxel.text(10, 20, f"TURNS {current.turns}", 7)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 10 + x * TILE, 40 + y * TILE
                pyxel.rect(px, py, TILE - 1, TILE - 1, 5 if char == "#" else 0)
        x, y = current.player
        pyxel.rect(16 + x * TILE, 46 + y * TILE, 12, 12, 12)
        pyxel.text(10, 190, "ARROWS: STEP  SPACE: WAIT", 6)
        pyxel.text(10, 202, "R: RESET  ESC: QUIT", 6)

    pyxel.init(188, 218, title="Echo Steps - Stage 1", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
    parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 02 / 05

影は一手前を歩く

前回の命令を、状態として覚えておく。

State に、影の位置 echo と、前回の命令 memory を追加します。自分は今回の命令 command で、影は覚えておいた memory で動きます。そのあとで、今回の command を次の手のために保存します。最初の memory は「待つ」命令の W です。壁に向かった入力も、待つ入力も、同じように記憶します。この段階では、自分と影が重なってもゲームは止まりません。

下のコードは 02_memory.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_memory.py / 注目する部分
def step(state, command):
    if command not in DIRECTIONS:
        return state
    player = move(state.player, command)
    # 新しい command を保存する前に、一手前の memory を使う。
    echo = move(state.echo, state.memory)
    return State(player=player, echo=echo, memory=command, turns=state.turns + 1)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_memory.py
macOS / Linux
.venv/bin/python 02_memory.py
画面で見ることピンクの丸が影です。最初に右を押すと、自分だけが動きます。次に右を押すと、影も右へ進みます。NEXT ECHO の文字が、影が次に使う命令を表しています。
  • 1手目の影は止まる
  • 2手目から前の入力が効く
  • 待つ命令も記憶する
つまずきやすいところ影を move(state.echo, command) で動かすと、自分と同時に動いてしまいます。1手遅らせるには state.memory を使い、影を動かしたあとで memory を保存します。
少し変えてみよう

最初の状態から「右、右、待つ」と入力する前に、自分の位置・影の位置・memory の3つがどうなるか、紙に予想してみましょう。最初の memory を R に変えたら1手目がどうなるかも、予想してみましょう。

02_memory.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(117行)
02_memory.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""一歩貸してください・教材 2/5。原作1部屋目の仕組みを段階化。
実行: python 02_memory.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass

ROWS = ("#######", "#P..G.#", "#.....#", "#E.S..#", "#######")
DIRECTIONS = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0), "W": (0, 0)}
TILE = 24


def find(char):
    return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char)


def move(position, command):
    dx, dy = DIRECTIONS[command]
    x, y = position[0] + dx, position[1] + dy
    if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#":
        return x, y
    return position


@dataclass(frozen=True)
class State:
    player: tuple = find("P")
    echo: tuple = find("E")
    memory: str = "W"
    turns: int = 0


def step(state, command):
    if command not in DIRECTIONS:
        return state
    player = move(state.player, command)
    # 新しい command を保存する前に、一手前の memory を使う。
    echo = move(state.echo, state.memory)
    return State(player=player, echo=echo, memory=command, turns=state.turns + 1)


def main(headless=False, screenshot=None):
    import pyxel

    state = State()
    keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"),
            (pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_SPACE, "W"))

    def update():
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        nonlocal state
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                state = step(state, command)
                break  # 同じフレームに複数の一手を進めない。

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 8, "ECHO STEPS / STEP 2", 10)
        pyxel.text(10, 20, f"TURNS {current.turns}", 7)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 10 + x * TILE, 40 + y * TILE
                pyxel.rect(px, py, TILE - 1, TILE - 1, 5 if char == "#" else 0)
        ex, ey = current.echo
        pyxel.circ(22 + ex * TILE, 52 + ey * TILE, 7, 14)
        pyxel.text(10, 175, f"NEXT ECHO: {current.memory}", 14)
        x, y = current.player
        pyxel.rect(16 + x * TILE, 46 + y * TILE, 12, 12, 12)
        pyxel.text(10, 190, "ARROWS: STEP  SPACE: WAIT", 6)
        pyxel.text(10, 202, "R: RESET  ESC: QUIT", 6)

    pyxel.init(188, 218, title="Echo Steps - Stage 2", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
    parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 03 / 05

動く順番で衝突を判定する

動く順番に気をつけて、ぶつかったかを調べる。

自分が先に動くので、自分が「影が今いる場所」に入ったらぶつかったことになります。続いて、影が動いたあとに重なっていないかも調べます。どちらかに当てはまれば status を hit にして、次の step からは動かないようにします。自分と影が場所を入れ替える「すれ違い」も、1つ目の条件でぶつかったと判定されます。R キーで最初に戻せます。

下のコードは 03_collision.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_collision.py / 注目する部分
# 自分が先に動くので、影がこれから退く場所にも入れない。
collision = player == state.echo or player == echo
status = "hit" if collision else "play"

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_collision.py
macOS / Linux
.venv/bin/python 03_collision.py
画面で見ること最初の位置から下、下と押すと、影がいた場所に入るので HIT になります。そのあとは動かなくなり、R キーで始め直せます。
  • 下→下でHIT
  • HIT後は位置と手数が止まる
  • すれ違いも許されない
つまずきやすいところ最後の player == echo だけを調べると、影がどく前の場所に入れたり、すれ違えたりしてしまいます。影が動く前の位置と比べる player == state.echo も必要です。
少し変えてみよう

「自分が影の元の位置に入る」「動いたあとに重なる」「離れた場所に動く」の3つの例を紙に書き、それぞれでどの条件が True になるか答えてみましょう。

03_collision.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(125行)
03_collision.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""一歩貸してください・教材 3/5。原作1部屋目の仕組みを段階化。
実行: python 03_collision.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass

ROWS = ("#######", "#P..G.#", "#.....#", "#E.S..#", "#######")
DIRECTIONS = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0), "W": (0, 0)}
TILE = 24


def find(char):
    return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char)


def move(position, command):
    dx, dy = DIRECTIONS[command]
    x, y = position[0] + dx, position[1] + dy
    if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#":
        return x, y
    return position


@dataclass(frozen=True)
class State:
    player: tuple = find("P")
    echo: tuple = find("E")
    memory: str = "W"
    status: str = "play"
    turns: int = 0


def step(state, command):
    if command not in DIRECTIONS:
        return state
    if state.status != "play":
        return state
    player = move(state.player, command)
    # 新しい command を保存する前に、一手前の memory を使う。
    echo = move(state.echo, state.memory)
    # 自分が先に動くので、影がこれから退く場所にも入れない。
    collision = player == state.echo or player == echo
    status = "hit" if collision else "play"
    return State(player=player, echo=echo, memory=command, status=status, turns=state.turns + 1)


def main(headless=False, screenshot=None):
    import pyxel

    state = State()
    keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"),
            (pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_SPACE, "W"))

    def update():
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        nonlocal state
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                state = step(state, command)
                break  # 同じフレームに複数の一手を進めない。

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 8, "ECHO STEPS / STEP 3", 10)
        pyxel.text(10, 20, f"TURNS {current.turns}", 7)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 10 + x * TILE, 40 + y * TILE
                pyxel.rect(px, py, TILE - 1, TILE - 1, 5 if char == "#" else 0)
        ex, ey = current.echo
        pyxel.circ(22 + ex * TILE, 52 + ey * TILE, 7, 14)
        pyxel.text(10, 175, f"NEXT ECHO: {current.memory}", 14)
        x, y = current.player
        pyxel.rect(16 + x * TILE, 46 + y * TILE, 12, 12, 12)
        pyxel.text(10, 190, "ARROWS: STEP  SPACE: WAIT", 6)
        pyxel.text(10, 202, "R: RESET  ESC: QUIT", 6)
        if current.status == "hit":
            pyxel.text(10, 164, "HIT!  R: RESET", 8)

    pyxel.init(188, 218, title="Echo Steps - Stage 3", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
    parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 04 / 05

影の床印で出口を開く

2つの位置の条件がそろったら、クリアにする。

S は影が乗るための床の印、G は出口です。その手で影が動いたあとに S に乗っているときだけ、自分は G に入れます。条件を満たさずに出口へ進もうとしたときは、自分の位置は元に戻りますが、影・記憶・手数は進みます。先にぶつかっていないかを確かめ、ぶつかっていなければ「出口」と「床の印」の両方を満たしたときに win(クリア)にします。

下のコードは 04_gate.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_gate.py / 注目する部分
if player == find("G") and echo != find("S"):
    player = state.player
# 自分が先に動くので、影がこれから退く場所にも入れない。
collision = player == state.echo or player == echo
status = "hit" if collision else "play"
if not collision and player == find("G") and echo == find("S"):
    status = "win"

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_gate.py
macOS / Linux
.venv/bin/python 04_gate.py
画面で見ることS と G が表示されます。影が床の印に乗ると色が変わります。最初から右を3回押すと、影が S に来た3手目で初めて出口に入れて、CLEAR になります。
  • 右→右→右でCLEAR
  • 影が床印にいないと出口に入れない
  • CLEAR後は止まる
つまずきやすいところ動く前の state.echo が S にいるかどうかで判定すると、影がこの手で乗った場合や離れた場合に、結果がずれてしまいます。判定には、動いたあとの echo を使います。
少し変えてみよう

R キーで最初に戻し、「右→右→右」で何が起きるか紙で追いかけてから、実際に試してみましょう。次に、S を別の床に移して、まだ解けるか試してみましょう。S と G は、必ず1つずつ残してください。

04_gate.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(139行)
04_gate.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""一歩貸してください・教材 4/5。原作1部屋目の仕組みを段階化。
実行: python 04_gate.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass

ROWS = ("#######", "#P..G.#", "#.....#", "#E.S..#", "#######")
DIRECTIONS = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0), "W": (0, 0)}
TILE = 24


def find(char):
    return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char)


def move(position, command):
    dx, dy = DIRECTIONS[command]
    x, y = position[0] + dx, position[1] + dy
    if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#":
        return x, y
    return position


@dataclass(frozen=True)
class State:
    player: tuple = find("P")
    echo: tuple = find("E")
    memory: str = "W"
    status: str = "play"
    turns: int = 0


def step(state, command):
    if command not in DIRECTIONS:
        return state
    if state.status != "play":
        return state
    player = move(state.player, command)
    # 新しい command を保存する前に、一手前の memory を使う。
    echo = move(state.echo, state.memory)
    if player == find("G") and echo != find("S"):
        player = state.player
    # 自分が先に動くので、影がこれから退く場所にも入れない。
    collision = player == state.echo or player == echo
    status = "hit" if collision else "play"
    if not collision and player == find("G") and echo == find("S"):
        status = "win"
    return State(player=player, echo=echo, memory=command, status=status, turns=state.turns + 1)


def main(headless=False, screenshot=None):
    import pyxel

    state = State()
    keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"),
            (pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_SPACE, "W"))

    def update():
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        nonlocal state
        if pyxel.btnp(pyxel.KEY_R):
            state = State()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                state = step(state, command)
                break  # 同じフレームに複数の一手を進めない。

    def draw():
        current = state
        pyxel.cls(1)
        pyxel.text(10, 8, "ECHO STEPS / STEP 4", 10)
        pyxel.text(10, 20, f"TURNS {current.turns}", 7)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 10 + x * TILE, 40 + y * TILE
                pyxel.rect(px, py, TILE - 1, TILE - 1, 5 if char == "#" else 0)
                if char == "S":
                    color = 10 if current.echo == (x, y) else 4
                    pyxel.rect(px + 5, py + 5, 14, 14, color)
                    pyxel.text(px + 10, py + 9, "S", 0)
                if char == "G":
                    color = 11 if current.echo == find("S") else 4
                    pyxel.rectb(px + 3, py + 3, 18, 19, color)
                    pyxel.text(px + 10, py + 9, "G", color)
        ex, ey = current.echo
        pyxel.circ(22 + ex * TILE, 52 + ey * TILE, 7, 14)
        pyxel.text(10, 175, f"NEXT ECHO: {current.memory}", 14)
        x, y = current.player
        pyxel.rect(16 + x * TILE, 46 + y * TILE, 12, 12, 12)
        pyxel.text(10, 190, "ARROWS: STEP  SPACE: WAIT", 6)
        pyxel.text(10, 202, "R: RESET  ESC: QUIT", 6)
        if current.status == "hit":
            pyxel.text(10, 164, "HIT!  R: RESET", 8)
        elif current.status == "win":
            pyxel.text(10, 164, "CLEAR!  R: PLAY AGAIN", 11)

    pyxel.init(188, 218, title="Echo Steps - Stage 4", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
    parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 05 / 05

一手前の状態を丸ごと戻す

履歴を積み重ねて、取り消し(Undo)を作る。

操作する前の State を Session.history に append(追加)しておき、Z キーで pop(最後の1つを取り出す)して戻します。State は frozen=True(あとから書き換えられない)なので、保存した過去の状態が途中で変わってしまうことはありません。位置だけでなく、memory・status・turns もまとめて戻すので、ぶつかる直前やクリアの前に戻って、別の手を選べます。R キーを押すと、履歴も空にします。

下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_undo.py / 注目する部分
class Session:
    def __init__(self):
        self.state = State()
        self.history = []

    def act(self, command):
        next_state = step(self.state, command)
        if next_state != self.state:
            self.history.append(self.state)
            self.state = next_state

    def undo(self):
        if self.history:
            self.state = self.history.pop()

    def restart(self):
        self.state = State()
        self.history.clear()

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること下→下でぶつかったら、Z キーでぶつかる直前の状態に戻れます。NEXT ECHO と手数も戻ります。履歴が空になるまで Z を押しても、エラーにならずに止まります。
  • 衝突からZで復帰
  • 記憶と手数も戻る
  • Rで履歴も消える
つまずきやすいところ位置だけを保存すると、影の次の命令が元に戻らず、同じ手をやり直せません。また、中身を書き換えられる(mutableな)同じ辞書を履歴に入れ続けるのも危険です。あとから書き換えると、過去の記録まで変わってしまうからです。この教材では、書き換えられない State を保存しています。
少し変えてみよう

「右→右→右」でクリアしたあとに Z を押し、もう一度右を押すと同じようにクリアになることを確かめましょう。また、R キーを押したあとに Z を押しても前のゲームに戻れないのはなぜか、説明してみましょう。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(161行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""一歩貸してください・教材 5/5。原作1部屋目の仕組みを段階化。
実行: python 05_undo.py
コード・図形: MIT。原作者と条件は隣の LICENSE を参照。
"""
from dataclasses import dataclass

ROWS = ("#######", "#P..G.#", "#.....#", "#E.S..#", "#######")
DIRECTIONS = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0), "W": (0, 0)}
TILE = 24


def find(char):
    return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char)


def move(position, command):
    dx, dy = DIRECTIONS[command]
    x, y = position[0] + dx, position[1] + dy
    if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#":
        return x, y
    return position


@dataclass(frozen=True)
class State:
    player: tuple = find("P")
    echo: tuple = find("E")
    memory: str = "W"
    status: str = "play"
    turns: int = 0


def step(state, command):
    if command not in DIRECTIONS:
        return state
    if state.status != "play":
        return state
    player = move(state.player, command)
    # 新しい command を保存する前に、一手前の memory を使う。
    echo = move(state.echo, state.memory)
    if player == find("G") and echo != find("S"):
        player = state.player
    # 自分が先に動くので、影がこれから退く場所にも入れない。
    collision = player == state.echo or player == echo
    status = "hit" if collision else "play"
    if not collision and player == find("G") and echo == find("S"):
        status = "win"
    return State(player=player, echo=echo, memory=command, status=status, turns=state.turns + 1)


class Session:
    def __init__(self):
        self.state = State()
        self.history = []

    def act(self, command):
        next_state = step(self.state, command)
        if next_state != self.state:
            self.history.append(self.state)
            self.state = next_state

    def undo(self):
        if self.history:
            self.state = self.history.pop()

    def restart(self):
        self.state = State()
        self.history.clear()


def main(headless=False, screenshot=None):
    import pyxel

    session = Session()
    keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"),
            (pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_SPACE, "W"))

    def update():
        if headless and pyxel.frame_count >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            return
        if pyxel.btnp(pyxel.KEY_Z):
            session.undo()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                session.act(command)
                break  # 同じフレームに複数の一手を進めない。

    def draw():
        current = session.state
        pyxel.cls(1)
        pyxel.text(10, 8, "ECHO STEPS / STEP 5", 10)
        pyxel.text(10, 20, f"TURNS {current.turns}", 7)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 10 + x * TILE, 40 + y * TILE
                pyxel.rect(px, py, TILE - 1, TILE - 1, 5 if char == "#" else 0)
                if char == "S":
                    color = 10 if current.echo == (x, y) else 4
                    pyxel.rect(px + 5, py + 5, 14, 14, color)
                    pyxel.text(px + 10, py + 9, "S", 0)
                if char == "G":
                    color = 11 if current.echo == find("S") else 4
                    pyxel.rectb(px + 3, py + 3, 18, 19, color)
                    pyxel.text(px + 10, py + 9, "G", color)
        ex, ey = current.echo
        pyxel.circ(22 + ex * TILE, 52 + ey * TILE, 7, 14)
        pyxel.text(10, 175, f"NEXT ECHO: {current.memory}", 14)
        x, y = current.player
        pyxel.rect(16 + x * TILE, 46 + y * TILE, 12, 12, 12)
        pyxel.text(10, 190, "ARROWS: STEP  SPACE: WAIT", 6)
        pyxel.text(10, 202, "Z: UNDO  R: RESET  ESC: QUIT", 6)
        if current.status == "hit":
            pyxel.text(10, 164, "HIT!  R: RESET", 8)
        elif current.status == "win":
            pyxel.text(10, 164, "CLEAR!  R: PLAY AGAIN", 11)

    pyxel.init(188, 218, title="Echo Steps - Stage 5", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest")
    parser.add_argument("--screenshot", help="PNG path used with --headless-smoke")
    args = parser.parse_args()
    main(headless=args.headless_smoke, screenshot=args.screenshot)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

完成版は、矢印キーか WASD で1歩動き、Space で待ち、Z で1手戻し、R で部屋をやり直します。Tab キーで5つの部屋から選べます。影が床の印 S に乗る手で、自分が出口 G に入ればクリアです。時間制限はありません。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
部屋練習用の1部屋仕組みの違う5部屋
表示英語・シンプルな図形日本語・移動アニメ・ヒント
挑戦クリアしたら R で始め直す部屋選び・最短手数・答えの自動探索
共通する仕組み1手遅れ / 壁への入力も記憶 / 衝突 / 床の印と出口 / Undo

完成版のコードで、次に読むところ

  1. model.py の State と step を開き、教材の player・echo にあたる p・e を見つけましょう。
  2. Room でマップをデータとして表し、ROOMS に5つの部屋を並べる方法を読みます。ルールを変えずに、ステージを増やせるやり方です。
  3. 教材の Undo と Session.undo を比べ、位置だけでなく、命令・手数・状態もまとめて戻していることを確かめましょう。
  4. solve は、答えを自動で探すための補助の処理です。まず自分で解いてから、あとで「幅優先探索」という探し方を学びましょう。なお、解けることと、遊んでおもしろいことは別の話です。
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る