パズルLESSON 68着地と移動能力を組み合わせるルールカード・パズル原作052

拾ったルールの作り方

跳ぶ、渡る、滑る。持てるカードは2枚だけ。
バネ・帆・すべり靴という、移動のしかたを変えるカードのルールを組み合わせて、途中で通るマスと最後に着地するマス、装備は2枚までという制限を作ります。

完成版の遊び方移動のカードを最大2枚組み合わせ、「通り過ぎた」ではなく「着地した」マスで2つの印を集めて、出口へ進みます。

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

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

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

完成版の主な操作:矢印移動、Tab台で編集、左右カード/Z着脱、X戻す、F予告、R再開

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

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

うまく表示されないとき

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

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

「1歩」の意味を、装備したカードのルールで組み替える。

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

1. 座標・盤面の文字・動かす前の検査
2. 途中で通るマスと、着地するマスを分ける
3. 装備の良い点と制限を、一緒に判定する
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上・Pyxel 2.9.9 / 変数・if・for・関数・タプル / 座標、dataclass/replace、ビット演算(数の各けたを0/1の印として使う計算)は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:一命令の移動。押して離す / 1:02以降、S/Pでバネ着脱 / 2:03以降、S/Pで帆着脱 / 3:04以降、S/Pですべり靴着脱 / X:05のみ、移動または着脱を一判断戻す / R:履歴ごと初期化。新しい押下を優先 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一歩進める場所を、盤面から決める

座標・盤面の文字・動かす前の検査。

元のゲームの状況 C の7列×5行の盤面を、文字列のタプルとして置きます。左上が (0,0) で、右へ行くと x が、下へ行くと y が増えます。S は開始の台 (1,1)、P はカードを着け外しする台 (2,3)、G は出口 (5,3) です。# は壁、o は穴、~ は水、丸い番号の1・2は印です。この段階では、印と出口は表示するだけで、拾うこともクリアの判定もありません。まずは1歩ずつの移動を完成させます。

State は、位置 pos と移動した回数 moves を持ちます。frozen=True の dataclass は、作った状態を直接書き換えないためのデータの箱です。replace(state, pos=...) は古い箱を残したまま、指定した部分だけが違う新しい箱を返します。描画の draw はこの箱を読むだけで、step が新しい箱を作る役です。

DIRS は上・右・下・左の順に並んだ (dx,dy) で、これを今の位置に足します。board.tile は、盤面の外を壁として返します。移動先が壁・穴・水なら、forecast(予告)は着地点を空にして、その理由を返します。step は、その予告が有効なときだけ位置と回数を変えます。先に動かしてからダメなら戻す作りより、入ってはいけないマスに一瞬入ってしまう不具合を防げます。

右の欄の NEXT FINAL LANDING は、今それぞれの矢印キーを1回押したら最後にどこに着くかの予告です。点は最後に使った向きの道筋、緑の枠は着地点です。予告の計算では State を変えません。画面の円がプレイヤーで、番号の丸(印)とは色と大きさが違います。

どの入力も、押して離すまでが1回の判断です。押しっぱなしで続けて動くことはありません。矢印キーの同時押しは受け付けず、操作キーを全部離してから押し直します。R(最初に戻す)は、新しく押したときにほかより優先されます。これは、このあとの段階でも共通です。

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

01_walk.py / 注目する部分
    if board.tile(target) in ("#", "o", "~"):
        return Forecast(state.pos, reason="BLOCKED TILE")
    return Forecast(target, (target,), (target,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ること右を1回押して離すと (2,1)、MOVES 1 です。印1の上に来ても、まだ拾いません。続けて下を押すと壁にはばまれ、(2,1)・MOVES 1 のまま BLOCKED TILE(通れないマス)と表示されます。R で (1,1)・MOVES 0 に戻ります。
  • 座標は、x が右、y が下に増える
  • 入れないマスを調べてから、新しい State を返す
  • 予告しても、状態は変わらない
つまずきやすいところgrid[x][y] と逆にしないでください。文字列は行の集まりなので grid[y][x] です。また、予告を表示しただけで移動回数を増やさないでください。
少し変えてみよう

PLAYER_COLOR = 11 を PLAYER_COLOR = 10 に1か所だけ変えて、起動し直してみましょう。プレイヤーの円が緑から黄色に変わり、右へ1回動いた位置 (2,1) と MOVES 1 は変わりません。確認したら11に戻します。

01_walk.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(259行)
01_walk.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""拾ったルール・第1段階。原作Cから、移動の規則を育てる教材。"""
from dataclasses import dataclass, replace

DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
D_NAMES = ("UP", "RIGHT", "DOWN", "LEFT")
PLAYER_COLOR = 11

@dataclass(frozen=True)
class Level:
    grid: tuple
    wind: int = 2

    def cells(self, char):
        return tuple((x, y) for y, row in enumerate(self.grid)
                     for x, value in enumerate(row) if value == char)

    def tile(self, pos):
        x, y = pos
        return self.grid[y][x] if 0 <= x < 7 and 0 <= y < 5 else "#"

    @property
    def start(self):
        return self.cells("S")[0]

    @property
    def marks(self):
        return self.cells("*")

    @property
    def goal(self):
        return self.cells("G")[0]

# 原作Cをそのまま使用。座標は0始まり、下方向が+y。
BOARD = Level(("#######", "#S*...#", "#.#o#.#", "#*P~.G#", "#######"), wind=2)

@dataclass(frozen=True)
class Forecast:
    final: tuple
    landings: tuple = ()
    crossed: tuple = ()
    reason: str = ""

    @property
    def valid(self):
        return bool(self.landings)

@dataclass(frozen=True)
class State:
    pos: tuple
    moves: int = 0


def initial(board):
    return State(board.start)


def forecast(board, state, direction):
    if type(direction) is not int or direction not in range(4):
        return Forecast(state.pos, reason="INVALID DIRECTION")
    dx, dy = DIRS[direction]
    target = (state.pos[0] + dx, state.pos[1] + dy)
    if board.tile(target) in ("#", "o", "~"):
        return Forecast(state.pos, reason="BLOCKED TILE")
    return Forecast(target, (target,), (target,))


def step(board, state, action):
    if not isinstance(action, (tuple, list)) or len(action) != 2:
        return state
    kind, direction = action
    if kind != "move":
        return state
    result = forecast(board, state, direction)
    if not result.valid:
        return state
    return replace(state, pos=result.final, moves=state.moves + 1)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial(BOARD)
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.preview = 1

    def commit(self, action):
        before = self.state
        after = step(BOARD, before, action)
        if before == after:
            self.message = (forecast(BOARD, before, action[1]).reason
                            if action[0] == "move" else "NO CARDS YET")
            return
        self.state = after
        if action[0] == "move":
            self.preview = action[1]
        self.message = action[0].upper() + " ACCEPTED"

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        # 新しく押されたRは解放待ちやクリア状態より優先。
        if "R" in fresh:
            self.reset()
            self.previous = down
            self.gated = True
            return
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key in D_NAMES:
            self.commit(("move", D_NAMES.index(key)))

    def draw(self, p):
        s = self.state
        gear, marks = getattr(s, "gear", 0), getattr(s, "marks", 0)
        p.cls(1)
        p.text(12, 10, "RULE CARDS / STAGE 1 / ORIGINAL C", 10)
        p.text(12, 25, "POS %s  MOVES %d  TICK %d" % (s.pos, s.moves, getattr(s, "tick", s.moves)), 7)
        p.text(12, 39, "MOVEMENT PRACTICE / MARKS ARE DISPLAY ONLY", 6)
        for x in range(7):
            p.text(29 + x * 32, 58, str(x), 13)
        for y, row in enumerate(BOARD.grid):
            p.text(5, 82 + y * 32, str(y), 13)
            for x, tile in enumerate(row):
                px, py = 16 + x * 32, 70 + y * 32
                color = 5 if tile == "#" else 12 if tile == "~" else 2
                p.rect(px, py, 30, 30, color)
                if tile == "o":
                    p.elli(px + 5, py + 10, 20, 14, 0)
                    p.ellib(px + 5, py + 10, 20, 14, 13)
                elif tile in ("S", "P", "G"):
                    p.rectb(px + 2, py + 2, 26, 26, 10 if tile != "G" else 11)
                    p.text(px + 4, py + 3, tile, 7)
                elif tile == "*":
                    number = BOARD.marks.index((x, y))
                    taken = bool(marks & (1 << number))
                    p.circ(px + 15, py + 15, 9, 3 if taken else 10)
                    p.text(px + 13, py + 13, str(number + 1), 7 if taken else 1)
                elif tile == "~":
                    p.line(px + 3, py + 12, px + 25, py + 12, 6)
                    p.line(px + 3, py + 21, px + 25, py + 21, 6)
        result = forecast(BOARD, s, self.preview)
        if result.valid:
            for x, y in result.crossed:
                p.circ(31 + x * 32, 85 + y * 32, 2, 6)
            x, y = result.final
            p.rectb(17 + x * 32, 71 + y * 32, 28, 28, 11)
        x, y = s.pos
        p.circ(31 + x * 32, 88 + y * 32, 7, PLAYER_COLOR)
        p.pset(33 + x * 32, 86 + y * 32, 1)
        p.text(256, 60, "NEXT FINAL LANDING", 7)
        for direction in range(4):
            f = forecast(BOARD, s, direction)
            y = 77 + direction * 34
            p.text(256, y, D_NAMES[direction] + " " + (str(f.final) if f.valid else "X"), 11 if f.valid else 8)
            p.text(256, y + 11, ("PATH " + str(len(f.crossed)) + " CELLS") if f.valid else f.reason, 6)
        p.text(12, 242, "S/P: EDIT PLATFORM  o: HOLE  ~: WATER", 7)
        p.text(12, 260, "NO CARDS YET / ARROWS MOVE ONE CELL", 6)
        p.text(12, 294, "NO CLEAR CONDITION IN THIS STAGE", 10)
        p.text(12, 312, self.message, 11)
        p.text(12, 330, 'ARROWS MOVE  R RESET', 7)
        p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6)

KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R')
DEMO_KEYS = ('RIGHT',)


def parse_args(argv=None):
    """引数検査はPyxelのimportより前に行う。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, len(DEMO_KEYS) * 2 + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(480, 360, title="Rule Cards / Lesson 1", fps=30,
           display_scale=2, headless=args.headless_smoke)
    mapping = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                app.draw(p)
                p.screen.save(args.screenshot, 1)
            print(json.dumps({"state": asdict(app.state),
                              "history_length": len(getattr(app, "history", ())) }, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in mapping.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 02 / 05

二歩のバネと、最後の着地点だけの印

途中で通るマスと、着地するマスを分ける。

1 キーでバネを着け外しできるようにします。着け外しできるのは、最後に止まっている場所が S か P のときだけです。カードは最初から袋に入っていて、地図の上で拾う操作はありません。今はバネ1枚だけを使います。装備 gear の「1のビット」(2進数の一番下のけた)が1なら歩幅2、0なら歩幅1です。gear ^ 1 は、そのビットだけを反転(0⇔1)させて着け外しします。

予告では、1回の命令の途中で通るマス cells と、着地するマス target を別々に作ります。バネを着けて S から右へ進むと、(2,1) を通って (3,1) に着地します。途中に壁が1つでもあれば、その命令は受け付けません。一方、穴と水は「着地するマスとして安全か」だけを調べます。だから穴や水の上は跳び越せますが、バネだけで水に着地することはできません。状況 C の (3,1) から下への2歩は、穴を越えても着地点 (3,3) が水なので受け付けられません。

この段階から2つの印を拾えるようにし、両方を持って G に着地するとクリアです。印には、盤面を上から読んだ順に1と2の番号が付いています。拾った印は marks の下の2けたのビットで覚えます。1 << index でその印のビットを作り、|(ビットOR)で加えます。同じ印にもう一度戻っても、拾った順の記録 order に重ねて加えることはしません。order は実際に初めて拾った順番で、クリアにこの順番が必要なわけではありません。

大事なのは、最後の着地点 result.final だけを見ることです。バネで印1を跳び越しても、拾ったことにはなりません。移動が成功すると moves と tick が1つ増え、着け外しでは tick だけが増えます。受け付けなかった操作では、どちらも増えません。印が足りないまま先に G に着いても、そのまま続けられます。

強い移動が、いつも便利とは限りません。(3,1) にバネで止まると、そこは台ではないので、その場で外せません。行き詰まっても、やられたり時間切れになったりはしないので、今は R でやり直します。カードを使わずに歩いてもクリアできる、元のゲームの作りをそのまま残しています。

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

02_spring.py / 注目する部分
    if result.final in board.marks:
        index = board.marks.index(result.final)
        if not marks & (1 << index):
            marks |= 1 << index
            order += (index + 1,)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_spring.py
macOS / Linux
.venv/bin/python 02_spring.py
画面で見ることR → 1 → 右 を1回ずつ押して離します。(3,1)、GEAR 1/2、MARKS 0/2、MOVES 1、TICK 2 です。印1の上を通っても、拾っていません。ここで 1 を押しても EDIT ONLY ON S OR P(着け外しは S か P だけ)と表示され、状態は変わりません。
  • バネは歩幅2、でも壁は越えられない
  • 印は、最後に着地したマスでだけ拾う
  • 着け外しでは、移動回数は増えない
つまずきやすいところcells のどれかが印なら拾う、という作りにしないでください。このゲームのふだんのルールでは、命令の最後の位置だけを見ます。壁は途中も調べ、穴と水は着地点だけを調べる、という違いも残します。
少し変えてみよう

stride = 2 if state.gear & SPRING else 1 の2だけを3に変えてみましょう。R → 1 → 右 で (4,1)、MARKS 0/2、MOVES 1 です。もう一度右を押すと、途中の壁で受け付けられません。これは元のゲームとは違う実験なので、確認したら2に戻します。

02_spring.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(321行)
02_spring.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""拾ったルール・第2段階。原作Cから、移動の規則を育てる教材。"""
from dataclasses import dataclass, replace

DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
D_NAMES = ("UP", "RIGHT", "DOWN", "LEFT")
PLAYER_COLOR = 11

@dataclass(frozen=True)
class Level:
    grid: tuple
    wind: int = 2

    def cells(self, char):
        return tuple((x, y) for y, row in enumerate(self.grid)
                     for x, value in enumerate(row) if value == char)

    def tile(self, pos):
        x, y = pos
        return self.grid[y][x] if 0 <= x < 7 and 0 <= y < 5 else "#"

    @property
    def start(self):
        return self.cells("S")[0]

    @property
    def marks(self):
        return self.cells("*")

    @property
    def goal(self):
        return self.cells("G")[0]

# 原作Cをそのまま使用。座標は0始まり、下方向が+y。
BOARD = Level(("#######", "#S*...#", "#.#o#.#", "#*P~.G#", "#######"), wind=2)

@dataclass(frozen=True)
class Forecast:
    final: tuple
    landings: tuple = ()
    crossed: tuple = ()
    reason: str = ""

    @property
    def valid(self):
        return bool(self.landings)

SPRING, SAIL, SLIDE = 1, 2, 4

@dataclass(frozen=True)
class State:
    pos: tuple
    gear: int = 0
    marks: int = 0
    status: str = "play"
    tick: int = 0
    moves: int = 0
    order: tuple = ()
    pairs: tuple = ()


def initial(board):
    return State(board.start)

AVAILABLE_CARDS = (1,)


def forecast(board, state, direction):
    if type(direction) is not int or direction not in range(4):
        return Forecast(state.pos, reason="INVALID DIRECTION")
    if state.status != "play":
        return Forecast(state.pos, reason="ALREADY CLEARED")
    dx, dy = DIRS[direction]
    stride = 2 if state.gear & SPRING else 1
    pos = state.pos
    landings, crossed = [], []
    reason = ""
    for _ in range(1):
        cells = tuple((pos[0] + dx * i, pos[1] + dy * i)
                      for i in range(1, stride + 1))
        target = cells[-1]
        if any(board.tile(cell) == "#" for cell in cells):
            reason = "WALL"
            break
        if board.tile(target) == "o":
            reason = "CANNOT LAND IN A HOLE"
            break
        if board.tile(target) == "~":
            reason = "WATER NEEDS SAIL"
            break
        landings.append(target)
        crossed.extend(cells)
        pos = target
    return Forecast(pos, tuple(landings), tuple(crossed),
                    reason if not landings else "")


def edit_reason(board, state, bit):
    if state.status != "play":
        return "ALREADY CLEARED"
    if type(bit) is not int or bit not in AVAILABLE_CARDS:
        return "CARD NOT AVAILABLE"
    if board.tile(state.pos) not in ("S", "P"):
        return "EDIT ONLY ON S OR P"
    gear = state.gear ^ bit
    if gear.bit_count() > 2:
        return "TWO CARDS MAXIMUM"
    return ""


def step(board, state, action):
    if not isinstance(action, (tuple, list)) or len(action) != 2:
        return state
    kind, value = action
    if kind == "edit":
        if edit_reason(board, state, value):
            return state
        gear = state.gear ^ value
        pairs = state.pairs
        if gear.bit_count() == 2 and gear not in pairs:
            pairs += (gear,)
        return replace(state, gear=gear, tick=state.tick + 1, pairs=pairs)
    if kind != "move":
        return state
    result = forecast(board, state, value)
    if not result.valid:
        return state
    marks, order = state.marks, state.order
    # 通過点・途中の着地点ではなく、命令が終わった位置だけを調べる。
    if result.final in board.marks:
        index = board.marks.index(result.final)
        if not marks & (1 << index):
            marks |= 1 << index
            order += (index + 1,)
    status = "won" if result.final == board.goal and marks == 3 else "play"
    return replace(state, pos=result.final, marks=marks, status=status,
                   tick=state.tick + 1, moves=state.moves + 1, order=order)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial(BOARD)
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.preview = 1

    def commit(self, action):
        before = self.state
        after = step(BOARD, before, action)
        if before == after:
            self.message = (forecast(BOARD, before, action[1]).reason
                            if action[0] == "move" else edit_reason(BOARD, before, action[1]))
            return
        self.state = after
        if action[0] == "move":
            self.preview = action[1]
        self.message = action[0].upper() + " ACCEPTED"

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        # 新しく押されたRは解放待ちやクリア状態より優先。
        if "R" in fresh:
            self.reset()
            self.previous = down
            self.gated = True
            return
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key in D_NAMES:
            self.commit(("move", D_NAMES.index(key)))
        elif key in ('1',):
            self.commit(("edit", (1, 2, 4)[int(key) - 1]))

    def draw(self, p):
        s = self.state
        gear, marks = getattr(s, "gear", 0), getattr(s, "marks", 0)
        p.cls(1)
        p.text(12, 10, "RULE CARDS / STAGE 2 / ORIGINAL C", 10)
        p.text(12, 25, "POS %s  MOVES %d  TICK %d" % (s.pos, s.moves, getattr(s, "tick", s.moves)), 7)
        p.text(12, 39, "MARKS %d/2  GEAR %d/2" % (marks.bit_count(), gear.bit_count()), 6)
        for x in range(7):
            p.text(29 + x * 32, 58, str(x), 13)
        for y, row in enumerate(BOARD.grid):
            p.text(5, 82 + y * 32, str(y), 13)
            for x, tile in enumerate(row):
                px, py = 16 + x * 32, 70 + y * 32
                color = 5 if tile == "#" else 12 if tile == "~" else 2
                p.rect(px, py, 30, 30, color)
                if tile == "o":
                    p.elli(px + 5, py + 10, 20, 14, 0)
                    p.ellib(px + 5, py + 10, 20, 14, 13)
                elif tile in ("S", "P", "G"):
                    p.rectb(px + 2, py + 2, 26, 26, 10 if tile != "G" else 11)
                    p.text(px + 4, py + 3, tile, 7)
                elif tile == "*":
                    number = BOARD.marks.index((x, y))
                    taken = bool(marks & (1 << number))
                    p.circ(px + 15, py + 15, 9, 3 if taken else 10)
                    p.text(px + 13, py + 13, str(number + 1), 7 if taken else 1)
                elif tile == "~":
                    p.line(px + 3, py + 12, px + 25, py + 12, 6)
                    p.line(px + 3, py + 21, px + 25, py + 21, 6)
        result = forecast(BOARD, s, self.preview)
        if result.valid:
            for x, y in result.crossed:
                p.circ(31 + x * 32, 85 + y * 32, 2, 6)
            x, y = result.final
            p.rectb(17 + x * 32, 71 + y * 32, 28, 28, 11)
        x, y = s.pos
        p.circ(31 + x * 32, 88 + y * 32, 7, PLAYER_COLOR)
        p.pset(33 + x * 32, 86 + y * 32, 1)
        p.text(256, 60, "NEXT FINAL LANDING", 7)
        for direction in range(4):
            f = forecast(BOARD, s, direction)
            y = 77 + direction * 34
            p.text(256, y, D_NAMES[direction] + " " + (str(f.final) if f.valid else "X"), 11 if f.valid else 8)
            p.text(256, y + 11, ("PATH " + str(len(f.crossed)) + " CELLS") if f.valid else f.reason, 6)
        p.text(12, 242, "S/P: EDIT PLATFORM  o: HOLE  ~: WATER", 7)
        p.text(12, 264, "1 SPRING " + ("ON" if gear & 1 else "OFF"), 11 if gear & 1 else 5)
        p.text(12, 294, "CLEARED / ORDER " + str(s.order) if s.status == "won" else "FINAL LANDING ONLY / NO DEATH OR TIME LIMIT", 10)
        p.text(12, 312, self.message, 11)
        p.text(12, 330, 'ARROWS MOVE  1 SPRING  R RESET', 7)
        p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6)

KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R', '1')
DEMO_KEYS = ('1', 'RIGHT')


def parse_args(argv=None):
    """引数検査はPyxelのimportより前に行う。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, len(DEMO_KEYS) * 2 + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(480, 360, title="Rule Cards / Lesson 2", fps=30,
           display_scale=2, headless=args.headless_smoke)
    mapping = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                app.draw(p)
                p.screen.save(args.screenshot, 1)
            print(json.dumps({"state": asdict(app.state),
                              "history_length": len(getattr(app, "history", ())) }, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in mapping.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 03 / 05

帆で水へ着地し、向かい風を禁止する

装備の良い点と制限を、一緒に判定する。

2 キーで帆を着け外しする処理を加えます。帆は gear の「2のビット」です。バネ1と帆2を両方装備した値は3ですが、「カード番号3」という意味ではありません。gear & SAIL で帆を着けているかを調べます。画面の GEAR は gear の値そのものではなく、bit_count で数えた装備の枚数です。

帆があれば、水を着地点として使えます。その代わり、表示された HEADWIND(向かい風)の方向へは一切動けません。元の状況 C では DOWN、つまり方向番号2が禁止です。風向きは「その向きへ動けない」という指定で、絵に描かれた風の吹く向きと考えないでください。水の上にいるかどうかに関係なく、帆を着けている間は、その方向が禁止です。forecast は、歩幅や地形を調べる前に、まずこの禁止を返します。

R から 下・下・右 と押すと、P (2,3) に止まります。途中で印2を拾うので MARKS 1/2 です。2 で帆を着け、右で水 (3,3) に着地します。ここで下を押すと HEADWIND で受け付けられません。2 で帆を外すこともできません。水の上で外せない理由を先に返すのは、元のゲームに合わせた順番です。左で P に戻ってから 2 を押せば外せます。

装備を変える処理と、移動する処理は別々の分かれ道です。着け外しの前には、①まだクリアしていないか、②カードが使えるか、③水の上で帆を外そうとしていないか、④台に止まっているか、⑤2枚を超えないか、を順に調べます。今はカードが2種類しかないので「3枚目」の制限にはまだ届きませんが、この検査を次のすべり靴で使います。

pairs は、これまでに装備した2枚の組み合わせを、重ならないように記録します。これは成績を振り返るための記録で、2枚組を使うこと自体はクリアの条件ではありません。水に入ることも必須ではなく、道と道具の両方を自由に選べるようにします。

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

03_sail.py / 注目する部分
    if state.gear & SAIL and direction == board.wind:
        return Forecast(state.pos, reason="HEADWIND")

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_sail.py
macOS / Linux
.venv/bin/python 03_sail.py
画面で見ることR → 下 → 下 → 右 → 2 → 右 で、(3,3)、MARKS 1/2、GEAR 1/2、MOVES 4、TICK 5 です。下は HEADWIND、2 は CANNOT REMOVE SAIL ON WATER(水の上では帆を外せない)で、どちらも数値は変わりません。左 → 2 で P (2,3)・GEAR 0/2・TICK 7 になります。
  • ビットの値と、装備の枚数を区別する
  • 帆の良い点と、向かい風の制限を一緒に作る
  • 水の上では、帆を外せない
つまずきやすいところ「水に着地できる」という条件だけを増やして、向かい風の制限を忘れないでください。バネ+帆でも、帆を着けている限り DOWN は禁止です。
少し変えてみよう

BOARD の行にある wind=2) を wind=1) に1か所変えてみましょう。R → 下 → 下 → 右 → 2 → 右 と押すと、最後の右が HEADWIND になり、P (2,3)・MOVES 3・TICK 4 のままです。表示も RIGHT に変わります。確認したら2に戻します。

03_sail.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(327行)
03_sail.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""拾ったルール・第3段階。原作Cから、移動の規則を育てる教材。"""
from dataclasses import dataclass, replace

DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
D_NAMES = ("UP", "RIGHT", "DOWN", "LEFT")
PLAYER_COLOR = 11

@dataclass(frozen=True)
class Level:
    grid: tuple
    wind: int = 2

    def cells(self, char):
        return tuple((x, y) for y, row in enumerate(self.grid)
                     for x, value in enumerate(row) if value == char)

    def tile(self, pos):
        x, y = pos
        return self.grid[y][x] if 0 <= x < 7 and 0 <= y < 5 else "#"

    @property
    def start(self):
        return self.cells("S")[0]

    @property
    def marks(self):
        return self.cells("*")

    @property
    def goal(self):
        return self.cells("G")[0]

# 原作Cをそのまま使用。座標は0始まり、下方向が+y。
BOARD = Level(("#######", "#S*...#", "#.#o#.#", "#*P~.G#", "#######"), wind=2)

@dataclass(frozen=True)
class Forecast:
    final: tuple
    landings: tuple = ()
    crossed: tuple = ()
    reason: str = ""

    @property
    def valid(self):
        return bool(self.landings)

SPRING, SAIL, SLIDE = 1, 2, 4

@dataclass(frozen=True)
class State:
    pos: tuple
    gear: int = 0
    marks: int = 0
    status: str = "play"
    tick: int = 0
    moves: int = 0
    order: tuple = ()
    pairs: tuple = ()


def initial(board):
    return State(board.start)

AVAILABLE_CARDS = (1, 2)


def forecast(board, state, direction):
    if type(direction) is not int or direction not in range(4):
        return Forecast(state.pos, reason="INVALID DIRECTION")
    if state.status != "play":
        return Forecast(state.pos, reason="ALREADY CLEARED")
    if state.gear & SAIL and direction == board.wind:
        return Forecast(state.pos, reason="HEADWIND")
    dx, dy = DIRS[direction]
    stride = 2 if state.gear & SPRING else 1
    pos = state.pos
    landings, crossed = [], []
    reason = ""
    for _ in range(1):
        cells = tuple((pos[0] + dx * i, pos[1] + dy * i)
                      for i in range(1, stride + 1))
        target = cells[-1]
        if any(board.tile(cell) == "#" for cell in cells):
            reason = "WALL"
            break
        if board.tile(target) == "o":
            reason = "CANNOT LAND IN A HOLE"
            break
        if board.tile(target) == "~" and not state.gear & SAIL:
            reason = "WATER NEEDS SAIL"
            break
        landings.append(target)
        crossed.extend(cells)
        pos = target
    return Forecast(pos, tuple(landings), tuple(crossed),
                    reason if not landings else "")


def edit_reason(board, state, bit):
    if state.status != "play":
        return "ALREADY CLEARED"
    if type(bit) is not int or bit not in AVAILABLE_CARDS:
        return "CARD NOT AVAILABLE"
    if bit == SAIL and state.gear & SAIL and board.tile(state.pos) == "~":
        return "CANNOT REMOVE SAIL ON WATER"
    if board.tile(state.pos) not in ("S", "P"):
        return "EDIT ONLY ON S OR P"
    gear = state.gear ^ bit
    if gear.bit_count() > 2:
        return "TWO CARDS MAXIMUM"
    return ""


def step(board, state, action):
    if not isinstance(action, (tuple, list)) or len(action) != 2:
        return state
    kind, value = action
    if kind == "edit":
        if edit_reason(board, state, value):
            return state
        gear = state.gear ^ value
        pairs = state.pairs
        if gear.bit_count() == 2 and gear not in pairs:
            pairs += (gear,)
        return replace(state, gear=gear, tick=state.tick + 1, pairs=pairs)
    if kind != "move":
        return state
    result = forecast(board, state, value)
    if not result.valid:
        return state
    marks, order = state.marks, state.order
    # 通過点・途中の着地点ではなく、命令が終わった位置だけを調べる。
    if result.final in board.marks:
        index = board.marks.index(result.final)
        if not marks & (1 << index):
            marks |= 1 << index
            order += (index + 1,)
    status = "won" if result.final == board.goal and marks == 3 else "play"
    return replace(state, pos=result.final, marks=marks, status=status,
                   tick=state.tick + 1, moves=state.moves + 1, order=order)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial(BOARD)
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.preview = 1

    def commit(self, action):
        before = self.state
        after = step(BOARD, before, action)
        if before == after:
            self.message = (forecast(BOARD, before, action[1]).reason
                            if action[0] == "move" else edit_reason(BOARD, before, action[1]))
            return
        self.state = after
        if action[0] == "move":
            self.preview = action[1]
        self.message = action[0].upper() + " ACCEPTED"

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        # 新しく押されたRは解放待ちやクリア状態より優先。
        if "R" in fresh:
            self.reset()
            self.previous = down
            self.gated = True
            return
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key in D_NAMES:
            self.commit(("move", D_NAMES.index(key)))
        elif key in ('1', '2'):
            self.commit(("edit", (1, 2, 4)[int(key) - 1]))

    def draw(self, p):
        s = self.state
        gear, marks = getattr(s, "gear", 0), getattr(s, "marks", 0)
        p.cls(1)
        p.text(12, 10, "RULE CARDS / STAGE 3 / ORIGINAL C", 10)
        p.text(12, 25, "POS %s  MOVES %d  TICK %d" % (s.pos, s.moves, getattr(s, "tick", s.moves)), 7)
        p.text(12, 39, "MARKS %d/2  GEAR %d/2  HEADWIND: %s" % (marks.bit_count(), gear.bit_count(), D_NAMES[BOARD.wind]), 6)
        for x in range(7):
            p.text(29 + x * 32, 58, str(x), 13)
        for y, row in enumerate(BOARD.grid):
            p.text(5, 82 + y * 32, str(y), 13)
            for x, tile in enumerate(row):
                px, py = 16 + x * 32, 70 + y * 32
                color = 5 if tile == "#" else 12 if tile == "~" else 2
                p.rect(px, py, 30, 30, color)
                if tile == "o":
                    p.elli(px + 5, py + 10, 20, 14, 0)
                    p.ellib(px + 5, py + 10, 20, 14, 13)
                elif tile in ("S", "P", "G"):
                    p.rectb(px + 2, py + 2, 26, 26, 10 if tile != "G" else 11)
                    p.text(px + 4, py + 3, tile, 7)
                elif tile == "*":
                    number = BOARD.marks.index((x, y))
                    taken = bool(marks & (1 << number))
                    p.circ(px + 15, py + 15, 9, 3 if taken else 10)
                    p.text(px + 13, py + 13, str(number + 1), 7 if taken else 1)
                elif tile == "~":
                    p.line(px + 3, py + 12, px + 25, py + 12, 6)
                    p.line(px + 3, py + 21, px + 25, py + 21, 6)
        result = forecast(BOARD, s, self.preview)
        if result.valid:
            for x, y in result.crossed:
                p.circ(31 + x * 32, 85 + y * 32, 2, 6)
            x, y = result.final
            p.rectb(17 + x * 32, 71 + y * 32, 28, 28, 11)
        x, y = s.pos
        p.circ(31 + x * 32, 88 + y * 32, 7, PLAYER_COLOR)
        p.pset(33 + x * 32, 86 + y * 32, 1)
        p.text(256, 60, "NEXT FINAL LANDING", 7)
        for direction in range(4):
            f = forecast(BOARD, s, direction)
            y = 77 + direction * 34
            p.text(256, y, D_NAMES[direction] + " " + (str(f.final) if f.valid else "X"), 11 if f.valid else 8)
            p.text(256, y + 11, ("PATH " + str(len(f.crossed)) + " CELLS") if f.valid else f.reason, 6)
        p.text(12, 242, "S/P: EDIT PLATFORM  o: HOLE  ~: WATER", 7)
        p.text(12, 264, "1 SPRING " + ("ON" if gear & 1 else "OFF"), 11 if gear & 1 else 5)
        p.text(167, 264, "2 SAIL " + ("ON" if gear & 2 else "OFF"), 11 if gear & 2 else 5)
        p.text(12, 280, "PAIRS " + str(s.pairs), 6)
        p.text(12, 294, "CLEARED / ORDER " + str(s.order) if s.status == "won" else "FINAL LANDING ONLY / NO DEATH OR TIME LIMIT", 10)
        p.text(12, 312, self.message, 11)
        p.text(12, 330, 'ARROWS MOVE  1 SPRING  2 SAIL  R RESET', 7)
        p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6)

KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R', '1', '2')
DEMO_KEYS = ('DOWN', 'DOWN', 'RIGHT', '2', 'RIGHT', 'DOWN')


def parse_args(argv=None):
    """引数検査はPyxelのimportより前に行う。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, len(DEMO_KEYS) * 2 + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(480, 360, title="Rule Cards / Lesson 3", fps=30,
           display_scale=2, headless=args.headless_smoke)
    mapping = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                app.draw(p)
                p.screen.save(args.screenshot, 1)
            print(json.dumps({"state": asdict(app.state),
                              "history_length": len(getattr(app, "history", ())) }, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in mapping.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 04 / 05

すべり続けても、拾うのは最後だけ

回数に上限のあるくり返しと、ルールの組み合わせ。

3 キーで、すべり靴を着け外しします。靴のビットの値は4です。キーの「3」とビットの「4」は別の番号です。靴を着けていると、1回の矢印の命令で、進める最後の場所まで同じ向きに進み続けます。歩幅はバネ、くり返しは靴、着地できる水と禁止の方向は帆が決めます。それぞれを別々に判定するので、装備の組み合わせを if 文で全部書き出さなくて済みます。

for のくり返しは最大7回です。7列×5行の盤面でまっすぐ進むので、この上限で道の終わりまで必ず届き、終わらないくり返し(無限ループ)も防げます。くり返しのたびに壁・穴・水を調べ、安全なら着地点のリスト landings に1つ加えます。次のマスに進めなくても、すでに1回でも進めていれば、その命令は有効です。たとえば靴だけで P へ向かうと、水の手前の P で止まれます。最初から1歩も進めなければ無効です。

landings には途中で足をついたマスも入りますが、印を拾うのは命令が終わったあとの最後の位置 final だけです。S で 3 → 右 と押すと、印1の上を通り過ぎて (5,1) で止まり、MARKS 0/2 のままです。バネ+靴でも、途中の印や台には反応しません。台の上を一瞬通っただけで着け外しの画面が開くこともありません。右の欄の予告は、最後の位置と、通るマスの数を示します。

3枚を同時に着けることはできません。S で 1 → 2 → 3 と押すと、3回目は TWO CARDS MAXIMUM(2枚まで)となり、tick も増えません。先に 1 でバネを外してから 3 で靴を着ければ、帆+靴の2枚組に替えられます。組み合わせを重ならないように記録する pairs にも、その組み替えが記録されます。

この段階で、元の状況 C のふだんの移動・着け外し・印・クリア条件がすべてそろいます。手順は、右で印1を取る → 左で S に戻る → 靴を着けて下で印2 → 右で P → 帆を着けて右で出口、です。最後の1回の命令は、水と床を通って G に着地します。「途中を通ること」と「そこで止まって判定すること」の違いを確かめてください。

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

04_slide.py / 注目する部分
        landings.append(target)
        crossed.extend(cells)
        pos = target
        if not state.gear & SLIDE:
            break

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_slide.py
macOS / Linux
.venv/bin/python 04_slide.py
画面で見ることR → 右 → 左 → 3 → 下 → 右 → 2 → 右 で、CLEARED、(5,3)、MARKS 2/2、GEAR 2/2、MOVES 5、TICK 7、ORDER (1,2) になります。装備は帆+靴で、2枚組の記録は (6,) です。
  • 歩幅とくり返しを、別々に決める
  • 途中で止まっても、1歩でも進めていれば有効
  • 3枚目は、着ける前に断る
つまずきやすいところすべって進む1マスごとに、移動回数を増やしたり印を拾ったりしないでください。矢印1回が1つの命令です。バネ+靴でも、毎回の歩幅は2のままです。
少し変えてみよう

R → 3 → 右 で (5,1) に着いても、MARKS 0/2 のままであることを確かめましょう。次に R → 1 → 2 → 3 と押します。3枚目は断られ、TICK 2・バネと帆の2枚のままです。1 → 3 と押せば、帆+靴に替えられます。

04_slide.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(330行)
04_slide.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""拾ったルール・第4段階。原作Cから、移動の規則を育てる教材。"""
from dataclasses import dataclass, replace

DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
D_NAMES = ("UP", "RIGHT", "DOWN", "LEFT")
PLAYER_COLOR = 11

@dataclass(frozen=True)
class Level:
    grid: tuple
    wind: int = 2

    def cells(self, char):
        return tuple((x, y) for y, row in enumerate(self.grid)
                     for x, value in enumerate(row) if value == char)

    def tile(self, pos):
        x, y = pos
        return self.grid[y][x] if 0 <= x < 7 and 0 <= y < 5 else "#"

    @property
    def start(self):
        return self.cells("S")[0]

    @property
    def marks(self):
        return self.cells("*")

    @property
    def goal(self):
        return self.cells("G")[0]

# 原作Cをそのまま使用。座標は0始まり、下方向が+y。
BOARD = Level(("#######", "#S*...#", "#.#o#.#", "#*P~.G#", "#######"), wind=2)

@dataclass(frozen=True)
class Forecast:
    final: tuple
    landings: tuple = ()
    crossed: tuple = ()
    reason: str = ""

    @property
    def valid(self):
        return bool(self.landings)

SPRING, SAIL, SLIDE = 1, 2, 4

@dataclass(frozen=True)
class State:
    pos: tuple
    gear: int = 0
    marks: int = 0
    status: str = "play"
    tick: int = 0
    moves: int = 0
    order: tuple = ()
    pairs: tuple = ()


def initial(board):
    return State(board.start)

AVAILABLE_CARDS = (1, 2, 4)


def forecast(board, state, direction):
    if type(direction) is not int or direction not in range(4):
        return Forecast(state.pos, reason="INVALID DIRECTION")
    if state.status != "play":
        return Forecast(state.pos, reason="ALREADY CLEARED")
    if state.gear & SAIL and direction == board.wind:
        return Forecast(state.pos, reason="HEADWIND")
    dx, dy = DIRS[direction]
    stride = 2 if state.gear & SPRING else 1
    pos = state.pos
    landings, crossed = [], []
    reason = ""
    for _ in range(7):
        cells = tuple((pos[0] + dx * i, pos[1] + dy * i)
                      for i in range(1, stride + 1))
        target = cells[-1]
        if any(board.tile(cell) == "#" for cell in cells):
            reason = "WALL"
            break
        if board.tile(target) == "o":
            reason = "CANNOT LAND IN A HOLE"
            break
        if board.tile(target) == "~" and not state.gear & SAIL:
            reason = "WATER NEEDS SAIL"
            break
        landings.append(target)
        crossed.extend(cells)
        pos = target
        if not state.gear & SLIDE:
            break
    return Forecast(pos, tuple(landings), tuple(crossed),
                    reason if not landings else "")


def edit_reason(board, state, bit):
    if state.status != "play":
        return "ALREADY CLEARED"
    if type(bit) is not int or bit not in AVAILABLE_CARDS:
        return "CARD NOT AVAILABLE"
    if bit == SAIL and state.gear & SAIL and board.tile(state.pos) == "~":
        return "CANNOT REMOVE SAIL ON WATER"
    if board.tile(state.pos) not in ("S", "P"):
        return "EDIT ONLY ON S OR P"
    gear = state.gear ^ bit
    if gear.bit_count() > 2:
        return "TWO CARDS MAXIMUM"
    return ""


def step(board, state, action):
    if not isinstance(action, (tuple, list)) or len(action) != 2:
        return state
    kind, value = action
    if kind == "edit":
        if edit_reason(board, state, value):
            return state
        gear = state.gear ^ value
        pairs = state.pairs
        if gear.bit_count() == 2 and gear not in pairs:
            pairs += (gear,)
        return replace(state, gear=gear, tick=state.tick + 1, pairs=pairs)
    if kind != "move":
        return state
    result = forecast(board, state, value)
    if not result.valid:
        return state
    marks, order = state.marks, state.order
    # 通過点・途中の着地点ではなく、命令が終わった位置だけを調べる。
    if result.final in board.marks:
        index = board.marks.index(result.final)
        if not marks & (1 << index):
            marks |= 1 << index
            order += (index + 1,)
    status = "won" if result.final == board.goal and marks == 3 else "play"
    return replace(state, pos=result.final, marks=marks, status=status,
                   tick=state.tick + 1, moves=state.moves + 1, order=order)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial(BOARD)
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.preview = 1

    def commit(self, action):
        before = self.state
        after = step(BOARD, before, action)
        if before == after:
            self.message = (forecast(BOARD, before, action[1]).reason
                            if action[0] == "move" else edit_reason(BOARD, before, action[1]))
            return
        self.state = after
        if action[0] == "move":
            self.preview = action[1]
        self.message = action[0].upper() + " ACCEPTED"

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        # 新しく押されたRは解放待ちやクリア状態より優先。
        if "R" in fresh:
            self.reset()
            self.previous = down
            self.gated = True
            return
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key in D_NAMES:
            self.commit(("move", D_NAMES.index(key)))
        elif key in ('1', '2', '3'):
            self.commit(("edit", (1, 2, 4)[int(key) - 1]))

    def draw(self, p):
        s = self.state
        gear, marks = getattr(s, "gear", 0), getattr(s, "marks", 0)
        p.cls(1)
        p.text(12, 10, "RULE CARDS / STAGE 4 / ORIGINAL C", 10)
        p.text(12, 25, "POS %s  MOVES %d  TICK %d" % (s.pos, s.moves, getattr(s, "tick", s.moves)), 7)
        p.text(12, 39, "MARKS %d/2  GEAR %d/2  HEADWIND: %s" % (marks.bit_count(), gear.bit_count(), D_NAMES[BOARD.wind]), 6)
        for x in range(7):
            p.text(29 + x * 32, 58, str(x), 13)
        for y, row in enumerate(BOARD.grid):
            p.text(5, 82 + y * 32, str(y), 13)
            for x, tile in enumerate(row):
                px, py = 16 + x * 32, 70 + y * 32
                color = 5 if tile == "#" else 12 if tile == "~" else 2
                p.rect(px, py, 30, 30, color)
                if tile == "o":
                    p.elli(px + 5, py + 10, 20, 14, 0)
                    p.ellib(px + 5, py + 10, 20, 14, 13)
                elif tile in ("S", "P", "G"):
                    p.rectb(px + 2, py + 2, 26, 26, 10 if tile != "G" else 11)
                    p.text(px + 4, py + 3, tile, 7)
                elif tile == "*":
                    number = BOARD.marks.index((x, y))
                    taken = bool(marks & (1 << number))
                    p.circ(px + 15, py + 15, 9, 3 if taken else 10)
                    p.text(px + 13, py + 13, str(number + 1), 7 if taken else 1)
                elif tile == "~":
                    p.line(px + 3, py + 12, px + 25, py + 12, 6)
                    p.line(px + 3, py + 21, px + 25, py + 21, 6)
        result = forecast(BOARD, s, self.preview)
        if result.valid:
            for x, y in result.crossed:
                p.circ(31 + x * 32, 85 + y * 32, 2, 6)
            x, y = result.final
            p.rectb(17 + x * 32, 71 + y * 32, 28, 28, 11)
        x, y = s.pos
        p.circ(31 + x * 32, 88 + y * 32, 7, PLAYER_COLOR)
        p.pset(33 + x * 32, 86 + y * 32, 1)
        p.text(256, 60, "NEXT FINAL LANDING", 7)
        for direction in range(4):
            f = forecast(BOARD, s, direction)
            y = 77 + direction * 34
            p.text(256, y, D_NAMES[direction] + " " + (str(f.final) if f.valid else "X"), 11 if f.valid else 8)
            p.text(256, y + 11, ("PATH " + str(len(f.crossed)) + " CELLS") if f.valid else f.reason, 6)
        p.text(12, 242, "S/P: EDIT PLATFORM  o: HOLE  ~: WATER", 7)
        p.text(12, 264, "1 SPRING " + ("ON" if gear & 1 else "OFF"), 11 if gear & 1 else 5)
        p.text(167, 264, "2 SAIL " + ("ON" if gear & 2 else "OFF"), 11 if gear & 2 else 5)
        p.text(322, 264, "3 SLIDE " + ("ON" if gear & 4 else "OFF"), 11 if gear & 4 else 5)
        p.text(12, 280, "PAIRS " + str(s.pairs), 6)
        p.text(12, 294, "CLEARED / ORDER " + str(s.order) if s.status == "won" else "FINAL LANDING ONLY / NO DEATH OR TIME LIMIT", 10)
        p.text(12, 312, self.message, 11)
        p.text(12, 330, 'ARROWS MOVE  1 SPRING  2 SAIL  3 SLIDE  R RESET', 7)
        p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6)

KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R', '1', '2', '3')
DEMO_KEYS = ('RIGHT', 'LEFT', '3', 'DOWN', 'RIGHT', '2', 'RIGHT')


def parse_args(argv=None):
    """引数検査はPyxelのimportより前に行う。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, len(DEMO_KEYS) * 2 + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(480, 360, title="Rule Cards / Lesson 4", fps=30,
           display_scale=2, headless=args.headless_smoke)
    mapping = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                app.draw(p)
                p.screen.save(args.screenshot, 1)
            print(json.dumps({"state": asdict(app.state),
                              "history_length": len(getattr(app, "history", ())) }, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in mapping.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 05 / 05

移動も着脱も、一判断ずつ戻す

書き換えない状態の履歴と、別の未来への分かれ道。

X で、直前に受け付けた判断を取り消せるようにします。commit では step で次の State を計算し、変化があったときだけ、変わる前の State を履歴 history に追加します。そのあとで今の状態を更新します。State の中身は、位置・整数・文字列・タプルだけなので、履歴に同じ State を入れても、あとから中身が書き換わることはありません。書き換えられるリストを State に入れる設計にした場合は、この性質を別の方法で守る必要があります。

Undo は history.pop で最後の State を取り出します。位置だけでなく、装備・印・拾った順・使った2枚組・移動回数・tick・クリアの状態を一緒に戻します。すべって4マス進んでも1つの命令なので、途中のマスではなく、命令の前の位置に戻ります。着け外しも1回の判断として戻せます。右の欄の予告は、戻した状態から計算し直されます。

受け付けなかった移動や、3枚目の着け外し、同時押しでは、履歴を追加しません。履歴が空のときに X を押すと NO HISTORY と表示し、何もしません。クリアしたあとも X を先に処理するので、最後の一手を試し直せます。R は履歴ごと消して、最初の状態に戻します。戻したあとに別の行動を選ぶと、そこから別の未来へ進みます。取り消しをやり直す Redo と、ファイルへの保存は、この教材には付けていません。

04 の成功の手順を行ってから X を押すと、帆と靴を着けた P (2,3) に戻ります。印は2つとも持ったままで、MOVES 4、TICK 6、状態 status は play です。右を押すと、また同じクリアになります。さらに戻して帆を着ける前まで行き、靴を外すなど、移動のしかたそのものを選び直すこともできます。

元のゲームとの照合では、04/05 を元の状況 A・B・C の盤面で比べています。ただし、教材の画面で選べるのは C だけです。位置・装備・印・終わりの状態を組み合わせた、限られた数の代表的な状態から、7種類の操作すべての結果を比べています。別に、乱数の種(seed)を固定した長めの行動の列で、履歴から決まる値も比べています。どんな長さの履歴でも全部調べた、というテストではありません。元のゲームにある「通過した印も拾う」比較、記録と再生、3つの面の切り替えは、次に読む項目として残しています。

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

05_undo.py / 注目する部分
        if key == "X":
            if self.history:
                self.state = self.history.pop()
                self.message = "UNDONE ONE DECISION"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04 の成功の手順 → X で、(2,3)、MARKS 2/2、GEAR 2/2、MOVES 4、TICK 6 のプレイ中に戻り、履歴は6件です。右を押すと、前と同じ CLEARED・MOVES 5・TICK 7 にもう一度着きます。自動確認は、X を押した直後の画面を描きます。
  • 受け付けた判断の、直前の状態だけを保存する
  • 装備・印・結果もまとめて戻す
  • 履歴が空のときと、クリア後の Undo を試す
つまずきやすいところ今の位置だけを戻すと、拾った印や装備が「未来」のまま残ってしまいます。State 全体を戻し、無効な操作を履歴に混ぜないでください。
少し変えてみよう

R → 右 → 左 → 3 → 下 → 右 → 2 → X と押すと、P にいて靴だけを着けた状態に戻ります。2枚組の記録も空に戻ります。3 で靴を外し、左で印2のマスへ1歩動いてみましょう。この別の未来を試したら、最後に R → X で NO HISTORY になることを確かめます。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(340行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""拾ったルール・第5段階。原作Cから、移動の規則を育てる教材。"""
from dataclasses import dataclass, replace

DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
D_NAMES = ("UP", "RIGHT", "DOWN", "LEFT")
PLAYER_COLOR = 11

@dataclass(frozen=True)
class Level:
    grid: tuple
    wind: int = 2

    def cells(self, char):
        return tuple((x, y) for y, row in enumerate(self.grid)
                     for x, value in enumerate(row) if value == char)

    def tile(self, pos):
        x, y = pos
        return self.grid[y][x] if 0 <= x < 7 and 0 <= y < 5 else "#"

    @property
    def start(self):
        return self.cells("S")[0]

    @property
    def marks(self):
        return self.cells("*")

    @property
    def goal(self):
        return self.cells("G")[0]

# 原作Cをそのまま使用。座標は0始まり、下方向が+y。
BOARD = Level(("#######", "#S*...#", "#.#o#.#", "#*P~.G#", "#######"), wind=2)

@dataclass(frozen=True)
class Forecast:
    final: tuple
    landings: tuple = ()
    crossed: tuple = ()
    reason: str = ""

    @property
    def valid(self):
        return bool(self.landings)

SPRING, SAIL, SLIDE = 1, 2, 4

@dataclass(frozen=True)
class State:
    pos: tuple
    gear: int = 0
    marks: int = 0
    status: str = "play"
    tick: int = 0
    moves: int = 0
    order: tuple = ()
    pairs: tuple = ()


def initial(board):
    return State(board.start)

AVAILABLE_CARDS = (1, 2, 4)


def forecast(board, state, direction):
    if type(direction) is not int or direction not in range(4):
        return Forecast(state.pos, reason="INVALID DIRECTION")
    if state.status != "play":
        return Forecast(state.pos, reason="ALREADY CLEARED")
    if state.gear & SAIL and direction == board.wind:
        return Forecast(state.pos, reason="HEADWIND")
    dx, dy = DIRS[direction]
    stride = 2 if state.gear & SPRING else 1
    pos = state.pos
    landings, crossed = [], []
    reason = ""
    for _ in range(7):
        cells = tuple((pos[0] + dx * i, pos[1] + dy * i)
                      for i in range(1, stride + 1))
        target = cells[-1]
        if any(board.tile(cell) == "#" for cell in cells):
            reason = "WALL"
            break
        if board.tile(target) == "o":
            reason = "CANNOT LAND IN A HOLE"
            break
        if board.tile(target) == "~" and not state.gear & SAIL:
            reason = "WATER NEEDS SAIL"
            break
        landings.append(target)
        crossed.extend(cells)
        pos = target
        if not state.gear & SLIDE:
            break
    return Forecast(pos, tuple(landings), tuple(crossed),
                    reason if not landings else "")


def edit_reason(board, state, bit):
    if state.status != "play":
        return "ALREADY CLEARED"
    if type(bit) is not int or bit not in AVAILABLE_CARDS:
        return "CARD NOT AVAILABLE"
    if bit == SAIL and state.gear & SAIL and board.tile(state.pos) == "~":
        return "CANNOT REMOVE SAIL ON WATER"
    if board.tile(state.pos) not in ("S", "P"):
        return "EDIT ONLY ON S OR P"
    gear = state.gear ^ bit
    if gear.bit_count() > 2:
        return "TWO CARDS MAXIMUM"
    return ""


def step(board, state, action):
    if not isinstance(action, (tuple, list)) or len(action) != 2:
        return state
    kind, value = action
    if kind == "edit":
        if edit_reason(board, state, value):
            return state
        gear = state.gear ^ value
        pairs = state.pairs
        if gear.bit_count() == 2 and gear not in pairs:
            pairs += (gear,)
        return replace(state, gear=gear, tick=state.tick + 1, pairs=pairs)
    if kind != "move":
        return state
    result = forecast(board, state, value)
    if not result.valid:
        return state
    marks, order = state.marks, state.order
    # 通過点・途中の着地点ではなく、命令が終わった位置だけを調べる。
    if result.final in board.marks:
        index = board.marks.index(result.final)
        if not marks & (1 << index):
            marks |= 1 << index
            order += (index + 1,)
    status = "won" if result.final == board.goal and marks == 3 else "play"
    return replace(state, pos=result.final, marks=marks, status=status,
                   tick=state.tick + 1, moves=state.moves + 1, order=order)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial(BOARD)
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.preview = 1
        self.history = []

    def commit(self, action):
        before = self.state
        after = step(BOARD, before, action)
        if before == after:
            self.message = (forecast(BOARD, before, action[1]).reason
                            if action[0] == "move" else edit_reason(BOARD, before, action[1]))
            return
        self.history.append(before)
        self.state = after
        if action[0] == "move":
            self.preview = action[1]
        self.message = action[0].upper() + " ACCEPTED"

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        # 新しく押されたRは解放待ちやクリア状態より優先。
        if "R" in fresh:
            self.reset()
            self.previous = down
            self.gated = True
            return
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key == "X":
            if self.history:
                self.state = self.history.pop()
                self.message = "UNDONE ONE DECISION"
            else:
                self.message = "NO HISTORY"
            return
        if key in D_NAMES:
            self.commit(("move", D_NAMES.index(key)))
        elif key in ('1', '2', '3'):
            self.commit(("edit", (1, 2, 4)[int(key) - 1]))

    def draw(self, p):
        s = self.state
        gear, marks = getattr(s, "gear", 0), getattr(s, "marks", 0)
        p.cls(1)
        p.text(12, 10, "RULE CARDS / STAGE 5 / ORIGINAL C", 10)
        p.text(12, 25, "POS %s  MOVES %d  TICK %d" % (s.pos, s.moves, getattr(s, "tick", s.moves)), 7)
        p.text(12, 39, "MARKS %d/2  GEAR %d/2  HEADWIND: %s" % (marks.bit_count(), gear.bit_count(), D_NAMES[BOARD.wind]), 6)
        for x in range(7):
            p.text(29 + x * 32, 58, str(x), 13)
        for y, row in enumerate(BOARD.grid):
            p.text(5, 82 + y * 32, str(y), 13)
            for x, tile in enumerate(row):
                px, py = 16 + x * 32, 70 + y * 32
                color = 5 if tile == "#" else 12 if tile == "~" else 2
                p.rect(px, py, 30, 30, color)
                if tile == "o":
                    p.elli(px + 5, py + 10, 20, 14, 0)
                    p.ellib(px + 5, py + 10, 20, 14, 13)
                elif tile in ("S", "P", "G"):
                    p.rectb(px + 2, py + 2, 26, 26, 10 if tile != "G" else 11)
                    p.text(px + 4, py + 3, tile, 7)
                elif tile == "*":
                    number = BOARD.marks.index((x, y))
                    taken = bool(marks & (1 << number))
                    p.circ(px + 15, py + 15, 9, 3 if taken else 10)
                    p.text(px + 13, py + 13, str(number + 1), 7 if taken else 1)
                elif tile == "~":
                    p.line(px + 3, py + 12, px + 25, py + 12, 6)
                    p.line(px + 3, py + 21, px + 25, py + 21, 6)
        result = forecast(BOARD, s, self.preview)
        if result.valid:
            for x, y in result.crossed:
                p.circ(31 + x * 32, 85 + y * 32, 2, 6)
            x, y = result.final
            p.rectb(17 + x * 32, 71 + y * 32, 28, 28, 11)
        x, y = s.pos
        p.circ(31 + x * 32, 88 + y * 32, 7, PLAYER_COLOR)
        p.pset(33 + x * 32, 86 + y * 32, 1)
        p.text(256, 60, "NEXT FINAL LANDING", 7)
        for direction in range(4):
            f = forecast(BOARD, s, direction)
            y = 77 + direction * 34
            p.text(256, y, D_NAMES[direction] + " " + (str(f.final) if f.valid else "X"), 11 if f.valid else 8)
            p.text(256, y + 11, ("PATH " + str(len(f.crossed)) + " CELLS") if f.valid else f.reason, 6)
        p.text(12, 242, "S/P: EDIT PLATFORM  o: HOLE  ~: WATER", 7)
        p.text(12, 264, "1 SPRING " + ("ON" if gear & 1 else "OFF"), 11 if gear & 1 else 5)
        p.text(167, 264, "2 SAIL " + ("ON" if gear & 2 else "OFF"), 11 if gear & 2 else 5)
        p.text(322, 264, "3 SLIDE " + ("ON" if gear & 4 else "OFF"), 11 if gear & 4 else 5)
        p.text(12, 280, "PAIRS " + str(s.pairs), 6)
        p.text(256, 280, "HISTORY " + str(len(self.history)), 6)
        p.text(12, 294, "CLEARED / ORDER " + str(s.order) if s.status == "won" else "FINAL LANDING ONLY / NO DEATH OR TIME LIMIT", 10)
        p.text(12, 312, self.message, 11)
        p.text(12, 330, 'ARROWS MOVE  1 SPRING  2 SAIL  3 SLIDE  X UNDO  R RESET', 7)
        p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6)

KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R', '1', '2', '3', 'X')
DEMO_KEYS = ('RIGHT', 'LEFT', '3', 'DOWN', 'RIGHT', '2', 'RIGHT', 'X')


def parse_args(argv=None):
    """引数検査はPyxelのimportより前に行う。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, len(DEMO_KEYS) * 2 + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(480, 360, title="Rule Cards / Lesson 5", fps=30,
           display_scale=2, headless=args.headless_smoke)
    mapping = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                app.draw(p)
                p.screen.save(args.screenshot, 1)
            print(json.dumps({"state": asdict(app.state),
                              "history_length": len(getattr(app, "history", ())) }, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in mapping.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
AFTER STEP 05

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

ルールのまとめ(元のゲームの状況 C、7×5):S か P で、3種類のカードを2枚まで着け外しできます。バネは歩幅2、帆は水に着地できるが DOWN が禁止、靴は進める所までまっすぐ進む。壁は途中で通ることもできず、穴には着地できません。印は命令の最後に着いたマスでだけ拾い、2つ持って G に着地すればクリア。やられることも時間制限もありません。

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

比べる点5ステップの教材元の完成版
段階と盤面01 は1歩の移動だけで、印と出口は表示だけ。02 バネ、03 帆、04 元の状況 C のふだんのルール、05 Undo。画面は状況 C だけ。状況 A/B/C の3つの面と、通り過ぎた印も拾う比較モードを選べます。
操作1/2/3 で、それぞれのカードを直接着け外し。操作キーが全部離されるのを待ち、同時押しは受け付けず、R は新しく押したときに優先。Tab で台の編集画面を開き、左右で選んで Z。タイトル、一時停止、予告の線の切り替え、面の切り替え、記録の書き出しもあります。
描画と履歴Pyxel 標準の英数字と、自作の図形。05 は書き換えない State の写し(スナップショット)をメモリーに保存。音なし、保存と再生なし。日本語の文字、カードの説明、移動のアニメーション、tick と盤面のハッシュ(hash)つきの記録と、再生による確認。

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

  1. model.py の forecast:cells / landings / final と、回数に上限のあるすべり(bounded slide)
  2. model.py の edit_reason:断る理由を調べる順番と、step の through(通過で拾う)比較
  3. model.py の Timeline / apply / undo:判断の前の状態を残す履歴
  4. model.py の record / replay:盤面・入力・最後の状態のハッシュ(hash)
  5. app.py:Tab での編集、4方向の予告、元の状況 A/B/C と DEMO
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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