完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーかWASDで移動、Spaceでその場で待つ、Zで1手戻す、Rでやり直し、Tabで部屋を選ぶ、Escで終了
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
ひとつ前の入力を記憶する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
今回の入力:右player + R
前の記憶:待つecho + W
次の影の命令:右memory = R
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・関数の基本
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 矢印:一歩 / Space:待つ / Z:戻す / R:やり直し
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
壁のあるマス目を歩く
画面上の位置(ピクセル)と、マス目の位置を分ける。
ROWS は文字で書いた地図で、# が壁、それ以外が床です。State.player はピクセルではなく、(列, 行) というマス目の位置を持ちます。move は、移動先が床なら進んだ位置を、壁なら元の位置を返します。マスの大きさ TILE を掛けてピクセルに直すのは、絵を描くときだけです。btnp はキーを押した瞬間だけ反応するので、1回押すと1マスだけ進みます。P は開始位置です。この段階では E・S・G もただの床として扱います。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_grid.py.venv/bin/python 01_grid.py- 1回押すと1マス
- 壁から外へ出ない
- 壁でも手数は増える
ROWS[x][y] と書くと、列と行が逆になります。文字の地図は「行→列」の順に読むので、ROWS[y][x] が正解です。座標は (x, y) の順にそろえて書きます。TILE の 24 を変えると、マス目の位置はそのままで、見た目の大きさだけが変わります。なぜそうなるか考えてみましょう(実際に変えるときは、ウィンドウの大きさや文字の位置も合わせて直します)。まずは、壁に向かって1手進んだらどうなるかを紙に書いてみましょう。
このステップのコード全文(110行)
# 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)影は一手前を歩く
前回の命令を、状態として覚えておく。
State に、影の位置 echo と、前回の命令 memory を追加します。自分は今回の命令 command で、影は覚えておいた memory で動きます。そのあとで、今回の command を次の手のために保存します。最初の memory は「待つ」命令の W です。壁に向かった入力も、待つ入力も、同じように記憶します。この段階では、自分と影が重なってもゲームは止まりません。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_memory.py.venv/bin/python 02_memory.py- 1手目の影は止まる
- 2手目から前の入力が効く
- 待つ命令も記憶する
move(state.echo, command) で動かすと、自分と同時に動いてしまいます。1手遅らせるには state.memory を使い、影を動かしたあとで memory を保存します。最初の状態から「右、右、待つ」と入力する前に、自分の位置・影の位置・memory の3つがどうなるか、紙に予想してみましょう。最初の memory を R に変えたら1手目がどうなるかも、予想してみましょう。
このステップのコード全文(117行)
# 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)動く順番で衝突を判定する
動く順番に気をつけて、ぶつかったかを調べる。
自分が先に動くので、自分が「影が今いる場所」に入ったらぶつかったことになります。続いて、影が動いたあとに重なっていないかも調べます。どちらかに当てはまれば status を hit にして、次の step からは動かないようにします。自分と影が場所を入れ替える「すれ違い」も、1つ目の条件でぶつかったと判定されます。R キーで最初に戻せます。
下のコードは 03_collision.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
# 自分が先に動くので、影がこれから退く場所にも入れない。
collision = player == state.echo or player == echo
status = "hit" if collision else "play"実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_collision.py.venv/bin/python 03_collision.py- 下→下でHIT
- HIT後は位置と手数が止まる
- すれ違いも許されない
player == echo だけを調べると、影がどく前の場所に入れたり、すれ違えたりしてしまいます。影が動く前の位置と比べる player == state.echo も必要です。「自分が影の元の位置に入る」「動いたあとに重なる」「離れた場所に動く」の3つの例を紙に書き、それぞれでどの条件が True になるか答えてみましょう。
このステップのコード全文(125行)
# 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)影の床印で出口を開く
2つの位置の条件がそろったら、クリアにする。
S は影が乗るための床の印、G は出口です。その手で影が動いたあとに S に乗っているときだけ、自分は G に入れます。条件を満たさずに出口へ進もうとしたときは、自分の位置は元に戻りますが、影・記憶・手数は進みます。先にぶつかっていないかを確かめ、ぶつかっていなければ「出口」と「床の印」の両方を満たしたときに win(クリア)にします。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_gate.py.venv/bin/python 04_gate.py- 右→右→右でCLEAR
- 影が床印にいないと出口に入れない
- CLEAR後は止まる
state.echo が S にいるかどうかで判定すると、影がこの手で乗った場合や離れた場合に、結果がずれてしまいます。判定には、動いたあとの echo を使います。R キーで最初に戻し、「右→右→右」で何が起きるか紙で追いかけてから、実際に試してみましょう。次に、S を別の床に移して、まだ解けるか試してみましょう。S と G は、必ず1つずつ残してください。
このステップのコード全文(139行)
# 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)一手前の状態を丸ごと戻す
履歴を積み重ねて、取り消し(Undo)を作る。
操作する前の State を Session.history に append(追加)しておき、Z キーで pop(最後の1つを取り出す)して戻します。State は frozen=True(あとから書き換えられない)なので、保存した過去の状態が途中で変わってしまうことはありません。位置だけでなく、memory・status・turns もまとめて戻すので、ぶつかる直前やクリアの前に戻って、別の手を選べます。R キーを押すと、履歴も空にします。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 衝突からZで復帰
- 記憶と手数も戻る
- Rで履歴も消える
State を保存しています。「右→右→右」でクリアしたあとに Z を押し、もう一度右を押すと同じようにクリアになることを確かめましょう。また、R キーを押したあとに Z を押しても前のゲームに戻れないのはなぜか、説明してみましょう。
このステップのコード全文(161行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
完成版は、矢印キーか WASD で1歩動き、Space で待ち、Z で1手戻し、R で部屋をやり直します。Tab キーで5つの部屋から選べます。影が床の印 S に乗る手で、自分が出口 G に入ればクリアです。時間制限はありません。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 部屋 | 練習用の1部屋 | 仕組みの違う5部屋 |
| 表示 | 英語・シンプルな図形 | 日本語・移動アニメ・ヒント |
| 挑戦 | クリアしたら R で始め直す | 部屋選び・最短手数・答えの自動探索 |
| 共通する仕組み | 1手遅れ / 壁への入力も記憶 / 衝突 / 床の印と出口 / Undo | |
完成版のコードで、次に読むところ
model.pyのStateとstepを開き、教材のplayer・echoにあたるp・eを見つけましょう。Roomでマップをデータとして表し、ROOMSに5つの部屋を並べる方法を読みます。ルールを変えずに、ステージを増やせるやり方です。- 教材の Undo と
Session.undoを比べ、位置だけでなく、命令・手数・状態もまとめて戻していることを確かめましょう。 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
