パズルLESSON 33ターン制配達パズル × 局所観測AI原作039

見回り犬とパン屋の作り方

新しい匂いで、犬の行き先が変わる。
パンの寿命(匂いが残る手数)、近くの匂いだけを嗅ぐこと、巡回(見回り)と追跡(匂いを追う)を分けて作ります。パンを配達する判断と、犬が見張る判断を作ります。

完成版の遊び方パンを餌として使うか、待って手元に残すかを選びます。近くの匂いだけを嗅ぐ犬をよけながら、三軒の家にパンを届けます。

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

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

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

完成版の主な操作:矢印キーで1歩動く、Zで配達・餌を置く、Spaceで待つ、Xで戻す、Pで一時停止、Rでやり直し、1〜3で状況を選ぶ、Mで選択

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

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

うまく表示されないとき

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

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

犬が嗅ぎとれる匂いだけで、行動を決める。

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

1. マスの座標、受け付けた手、期限切れ
2. 床を通る距離、嗅げる範囲の境目、新しさと同点のときの決まり
3. 判断のモード、巡回の番号、匂いが消えたあとに巡回へ戻る
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上と仮想環境での実行 / if・for・関数・タプル・辞書 / クラスと dataclass の初歩。frozen と replace、BFS(幅優先探索)、bit_count は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:1回押すと1歩動く / Space:待つ / Z:パンを置く(04以降は、まだ配達していない家の玄関では配達) / R:最初に戻す / X:戻す(05だけ) / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_scent.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一歩とパンの寿命を数える

マスの座標、受け付けた手、期限切れ。

元のゲームの面Cの地図から始めます。まずは、プレイヤーとパンだけを動かします。動けるかどうかは、マスの座標で調べます。行き先が床でなければ、同じ World(ゲーム全体の状態)を返すので、時間は進みません。Z でパンを1個置くと、置いたあとの新しい手数を birth(置かれた時刻)として記録します。匂いのもと(匂い源)は、turn-birth < life の間だけ残ります。餌の life(寿命)は8です。犬、配達、勝ち負けはまだありません。あとで玄関になる場所でも、Z は餌を置くだけです。パンは閉じた箱に入れて持ち歩くので、歩くだけでは匂いは出ません。

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

01_scent.py / 注目する部分
    return tuple(src for src in s.sources if s.turn-src.birth < src.life)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_scent.py
macOS / Linux
.venv/bin/python 01_scent.py
画面で見ること上は外側の壁なので、上を押しても TURN 0 のままです。Z のあとに Space を7回押すと TURN 8 になり、置いたパンの残り寿命は1です。さらに Space を1回押すと TURN 9 になり、パンが消えます。最初からある灰色のパンは birth=-3 なので、TURN 5 で消えます。
  • 壁に向かって押しても、手数が増えない
  • パンを置いた手では、そのパンの年齢は0
  • 8手たつと消え、何も入力しなければ残る
つまずきやすいところlife を毎フレーム(画面の更新1回ごと)減らすと、考えている間にもパンが消えてしまいます。Pyxel の frame_count と、ゲームの turn を混ぜないようにします。turn は、受け付けた行動のときだけ1増やします。パンを置いた手を年齢0とすることも大切です。
少し変えてみよう

Source(next_id, player, turn, 8, "bait") の8だけを4に変えてみましょう。Z のあと Space を3回押すと残り1、4回目で消えると予想してから確かめます。test_exercise_lifetime_change は、同じ境目を、モデル(ゲームのルール部分)だけで確かめるテストです。これは、元のゲームの8手より寿命を短くする改造です。

01_scent.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(238行)
01_scent.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/5。日本語の解説はLESSON.md。1ファイルで動作。"""
from dataclasses import dataclass, replace, asdict

# マスの順序も仕様:現在、上、下、左、右。同点なら先を選ぶ。
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
ACTIONS = dict(zip("UDLR", DIRS))

@dataclass(frozen=True)
class Source:
    id: int
    pos: tuple
    birth: int
    life: int
    kind: str

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    dog: tuple
    patrol: tuple
    doors: tuple
    bread: int
    old: tuple = ()

# 原作Cと同じ配置。最初の灰色パンは別の店の残りで、所持数に含めない。
BOARD = Level("近い古いパン、遠い新しいパン", 11, 7,
              ((5, 2), (5, 3), (5, 4)), (4, 1), (7, 2),
              ((7, 2), (8, 2), (8, 4)), ((2, 1), (8, 1), (9, 5)), 5,
              (Source(0, (7, 4), -3, 8, "old"),))

@dataclass(frozen=True)
class World:
    player: tuple
    bread: int
    sources: tuple = ()
    turn: int = 0
    next_id: int = 1
    reason: str = "ready"

def initial(b=BOARD):
    return World(b.start, b.bread, sources=b.old)

def floor(b, p):
    return 0 < p[0] < b.width-1 and 0 < p[1] < b.height-1 and p not in b.walls

def active(s):
    # 年齢が寿命と同じになった手で消える。フレーム数は使わない。
    return tuple(src for src in s.sources if s.turn-src.birth < src.life)

def step(b, s, action):
    player, bread, sources, next_id = s.player, s.bread, s.sources, s.next_id
    turn = s.turn+1
    reason = "walk"
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        player = (player[0]+dx, player[1]+dy)
        if not floor(b, player):
            return s  # 壁への入力は時刻を進めない。
    elif action == "Z":
        if bread <= 0:
            return s
        bread -= 1
        sources += (Source(next_id, player, turn, 8, "bait"),)
        next_id += 1
        reason = "bait"
    elif action == "W":
        reason = "wait"
    else:
        return s
    s = replace(s, player=player, bread=bread, sources=sources, next_id=next_id,
                turn=turn, reason=reason)
    s = replace(s, sources=active(s))
    return s

KEY_ACTION = {"UP": "U", "DOWN": "D", "LEFT": "L", "RIGHT": "R", "SPACE": "W", "Z": "Z"}
CONTROL_KEYS = tuple(KEY_ACTION)+("R",)

class App:
    def __init__(self):
        self.locked = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.message = "READY"

    def control(self, pressed, held):
        # R直後は、全操作キーを離したフレームを1回通ってから再受付。
        if self.locked:
            if not held.intersection(CONTROL_KEYS):
                self.locked = False
            return
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        chosen = [KEY_ACTION[key] for key in KEY_ACTION if key in pressed]
        action_held = sum(key in held for key in KEY_ACTION)
        # 新しく押したキーだけでなく、押し続けている行動キーも数える。
        if len(chosen) == 1 and action_held == 1:
            before = self.state
            self.state = step(BOARD, self.state, chosen[0])
            self.message = self.state.reason.upper() if self.state != before else "BLOCKED / NO TURN"

    def update(self, p):
        # 実Pyxelもテストも、このポーリング入口とcontrolを通る。
        pressed = {key for key in CONTROL_KEYS if p.btnp(getattr(p, "KEY_"+key))}
        held = {key for key in CONTROL_KEYS if p.btn(getattr(p, "KEY_"+key))}
        self.control(pressed, held)

    def draw(self, p):
        s, b = self.state, BOARD
        p.cls(1)
        p.text(12, 9, "WATCHDOG BAKERY / STAGE 1", 10)
        p.text(12, 23, "WALK / PLACE / COUNT SCENT AGE", 6)
        p.text(12, 38, "TURN "+str(s.turn)+"   BREAD "+str(s.bread), 7)
        p.text(180, 38, "PRACTICE / NO ENDING", 13)
        for y in range(b.height):
            for x in range(b.width):
                cell = (x, y)
                px, py = 12+x*28, 61+y*28
                if not floor(b, cell):
                    p.rect(px, py, 27, 27, 5)
                    p.line(px+2, py+9, px+24, py+9, 13)
                    p.line(px+2, py+19, px+24, py+19, 13)
                    continue
                p.rectb(px, py, 27, 27, 13)
        for src in active(s):
            x, y = 26+src.pos[0]*28, 75+src.pos[1]*28
            color = 13 if src.kind == "old" else 10
            p.circ(x-5, y+3, 4, color)
            p.rect(x-9, y+3, 9, 4, color)
            p.text(x-11, y-11, str(src.life-(s.turn-src.birth)), color)
        x, y = 26+s.player[0]*28, 75+s.player[1]*28
        p.circ(x, y-6, 4, 12)
        p.rect(x-4, y-1, 9, 9, 12)
        p.rect(x+4, y+1, 5, 5, 4)
        p.text(12, 266, self.message, 10)
        p.text(12, 282, "NO DOG YET / BREAD LIFE 8 TURNS", 6)
        p.text(12, 298, "ARROWS MOVE  SPACE WAIT  Z PLACE", 7)
        p.text(12, 310, "R RESET   ESC QUIT", 6)

# 合成キー列を実updateへ渡す検証用。OSのキー操作や人の試遊ではない。
DEMO_ACTIONS = "ZWWWWWWWW"

def main(headless=False, screenshot=None, checkpoint=False, actions=None, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p
    if screenshot:
        screenshot = str(Path(screenshot).resolve())
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    state_json = str(Path(state_json).resolve()) if state_json else None
    app = App()
    plan = actions if actions is not None else DEMO_ACTIONS if checkpoint else ""
    inverse = {action: key for key, action in KEY_ACTION.items()}
    inverse.update({"X": "X", "!": "R"})
    if any(action not in inverse or inverse[action] not in CONTROL_KEYS for action in plan):
        raise ValueError("Unsupported action in --actions")
    frames = max(4, 2*len(plan)+3)
    p.init(332, 328, title="039 Watchdog Bakery / 1", fps=30, display_scale=2,
           headless=headless)

    class FrameInput:
        def __init__(self, pressed):
            self.pressed = pressed
        def __getattr__(self, name):
            if name.startswith("KEY_"):
                return name[4:]
            raise AttributeError(name)
        def btnp(self, key):
            return key in self.pressed
        def btn(self, key):
            return key in self.pressed

    def update():
        if headless:
            index = p.frame_count//2
            pressed = {inverse[plan[index]]} if p.frame_count%2 == 0 and index < len(plan) else set()
            app.update(FrameInput(pressed))
        else:
            app.update(p)

    def draw():
        app.draw(p)
        if headless and p.frame_count >= frames:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"stage": 1, "actions": plan,
                    "state": asdict(app.state), "message": app.message,
                    "evidence": "Real Pyxel headless drawing; synthetic update input; no OS input"},
                    ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Independent Pyxel tutorial / Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--actions")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.actions, args.state_json)
STEP 02 / 05

犬に渡す情報を局所観測へ絞る

床を通る距離、嗅げる範囲の境目、新しさと同点のときの決まり。

幅優先探索(近い場所から順に広げて調べる方法)で、匂い源から床を通って進む距離(床距離)を求めます。各マス(セル)は、床距離4以内の匂いだけを受け取ります。そして、新しさ、距離、IDの順に比べて、1件だけを残します。犬は、今いるマスと、歩ける上下左右のマスの記録だけを比べます。犬の policy(行動を決める部分)には、プレイヤーの本当の位置や、すべての匂い源は渡しません。この段階では、匂いがなければ犬は止まったままです。犬は匂いを追いかけても、まだパンを食べません。プレイヤーとの接触、巡回、配達もありません。

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

02_local.py / 注目する部分
        index, r = min(found, key=lambda item: (-item[1].birth, item[1].distance,
                                               item[0], item[1].source_id))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_local.py
macOS / Linux
.venv/bin/python 02_local.py
画面で見ること始めてすぐに Z を押します。新しいパンは(4,1)、古いパンは(7,4)にあります。それでも犬は、(7,2)から上の(7,1)へ動きます。近い古いパンへ下に動くより、新しい匂いを優先したからです。DOG: CHASE は、今の観測(犬が嗅いだ情報)から決めた判断です。SOURCE は、犬が選んだ匂いのIDです。
  • 壁を回りこむ距離が分かる
  • 犬が受け取る情報に、プレイヤーの位置は入っていない
  • 1つのマスの中での同点と、マスどうしの同点を分けて考える
つまずきやすいところ犬からプレイヤーまでの最短経路を毎回求めると、壁の向こうまで見通せる「透視AI」になってしまいます。すべての床の距離をまとめた辞書は、環境(ゲームの世界の側)が匂いを計算するために使います。犬には、今いるマスと、となりのマスの Record(匂いの記録)だけを渡します。
少し変えてみよう

同じ birth の匂いを、(7,3)にID5、(8,2)にID3で置いた場合を考えます。犬が今いるマス(7,2)では、ID3が選ばれます。一方、行き先としては、下の(7,3)のID5が選ばれます。なぜ最後に勝つのがID3ではないのでしょうか。マスの中の比較と、マスどうしの比較という2段階で説明してみましょう。test_source_tie_uses_smaller_ID_within_cell で確かめます。

02_local.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(312行)
02_local.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/5。日本語の解説はLESSON.md。1ファイルで動作。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache

# マスの順序も仕様:現在、上、下、左、右。同点なら先を選ぶ。
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
ACTIONS = dict(zip("UDLR", DIRS))

@dataclass(frozen=True)
class Source:
    id: int
    pos: tuple
    birth: int
    life: int
    kind: str

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    dog: tuple
    patrol: tuple
    doors: tuple
    bread: int
    old: tuple = ()

# 原作Cと同じ配置。最初の灰色パンは別の店の残りで、所持数に含めない。
BOARD = Level("近い古いパン、遠い新しいパン", 11, 7,
              ((5, 2), (5, 3), (5, 4)), (4, 1), (7, 2),
              ((7, 2), (8, 2), (8, 4)), ((2, 1), (8, 1), (9, 5)), 5,
              (Source(0, (7, 4), -3, 8, "old"),))

@dataclass(frozen=True)
class World:
    player: tuple
    dog: tuple
    bread: int
    sources: tuple = ()
    turn: int = 0
    next_id: int = 1
    reason: str = "ready"
    picked: int = -1

def initial(b=BOARD):
    return World(b.start, b.dog, b.bread, sources=b.old)

def floor(b, p):
    return 0 < p[0] < b.width-1 and 0 < p[1] < b.height-1 and p not in b.walls

@lru_cache(None)
def distances(b, start):
    # 幅優先探索。壁を通らず、床の最短歩数を辞書へ記録する。
    distance = {start: 0}
    queue = deque([start])
    while queue:
        x, y = queue.popleft()
        for dx, dy in DIRS:
            cell = (x+dx, y+dy)
            if floor(b, cell) and cell not in distance:
                distance[cell] = distance[(x, y)]+1
                queue.append(cell)
    return distance

def active(s):
    # 年齢が寿命と同じになった手で消える。フレーム数は使わない。
    return tuple(src for src in s.sources if s.turn-src.birth < src.life)

@dataclass(frozen=True)
class Record:
    birth: int
    distance: int
    source_id: int

def scent(b, s, cell):
    records = []
    for src in active(s):
        # この段階には調査済み配達箱がない。
        distance = distances(b, src.pos).get(cell, 999)
        if distance <= 4:
            records.append(Record(src.birth, distance, src.id))
    # 各セルで、新しい誕生日→短い距離→小さいID。
    return min(records, key=lambda r: (-r.birth, r.distance, r.source_id), default=None)

@dataclass(frozen=True)
class Observation:
    cells: tuple
    records: tuple

@dataclass(frozen=True)
class Intent:
    cell: tuple
    source_id: int = -1
    reason: str = "idle"

def observation(b, s):
    cells = (s.dog,)+tuple((s.dog[0]+dx, s.dog[1]+dy) for dx, dy in DIRS
                          if floor(b, (s.dog[0]+dx, s.dog[1]+dy)))
    return Observation(cells, tuple(scent(b, s, cell) for cell in cells))

def policy(o):
    # 犬へ渡すのは局所観測だけ。プレイヤー位置や全匂い源は渡さない。
    found = [(index, r) for index, r in enumerate(o.records) if r is not None]
    if found:
        index, r = min(found, key=lambda item: (-item[1].birth, item[1].distance,
                                               item[0], item[1].source_id))
        return Intent(o.cells[index], r.source_id, "scent")
    return Intent(o.cells[0])  # 02は匂いがなければ静止する簡略版。

def preview(b, s):
    return policy(observation(b, s))

def step(b, s, action):
    player, bread, sources, next_id = s.player, s.bread, s.sources, s.next_id
    turn = s.turn+1
    reason = "walk"
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        player = (player[0]+dx, player[1]+dy)
        if not floor(b, player):
            return s  # 壁への入力は時刻を進めない。
    elif action == "Z":
        if bread <= 0:
            return s
        bread -= 1
        sources += (Source(next_id, player, turn, 8, "bait"),)
        next_id += 1
        reason = "bait"
    elif action == "W":
        reason = "wait"
    else:
        return s
    s = replace(s, player=player, bread=bread, sources=sources, next_id=next_id,
                turn=turn, reason=reason)
    s = replace(s, sources=active(s))
    intent = preview(b, s)
    return replace(s, dog=intent.cell, picked=intent.source_id)

KEY_ACTION = {"UP": "U", "DOWN": "D", "LEFT": "L", "RIGHT": "R", "SPACE": "W", "Z": "Z"}
CONTROL_KEYS = tuple(KEY_ACTION)+("R",)

class App:
    def __init__(self):
        self.locked = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.message = "READY"

    def control(self, pressed, held):
        # R直後は、全操作キーを離したフレームを1回通ってから再受付。
        if self.locked:
            if not held.intersection(CONTROL_KEYS):
                self.locked = False
            return
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        chosen = [KEY_ACTION[key] for key in KEY_ACTION if key in pressed]
        action_held = sum(key in held for key in KEY_ACTION)
        # 新しく押したキーだけでなく、押し続けている行動キーも数える。
        if len(chosen) == 1 and action_held == 1:
            before = self.state
            self.state = step(BOARD, self.state, chosen[0])
            self.message = self.state.reason.upper() if self.state != before else "BLOCKED / NO TURN"

    def update(self, p):
        # 実Pyxelもテストも、このポーリング入口とcontrolを通る。
        pressed = {key for key in CONTROL_KEYS if p.btnp(getattr(p, "KEY_"+key))}
        held = {key for key in CONTROL_KEYS if p.btn(getattr(p, "KEY_"+key))}
        self.control(pressed, held)

    def draw(self, p):
        s, b = self.state, BOARD
        p.cls(1)
        p.text(12, 9, "WATCHDOG BAKERY / STAGE 2", 10)
        p.text(12, 23, "LOCAL OBSERVATION / FRESHNESS FIRST", 6)
        p.text(12, 38, "TURN "+str(s.turn)+"   BREAD "+str(s.bread), 7)
        p.text(180, 38, "PRACTICE / NO ENDING", 13)
        for y in range(b.height):
            for x in range(b.width):
                cell = (x, y)
                px, py = 12+x*28, 61+y*28
                if not floor(b, cell):
                    p.rect(px, py, 27, 27, 5)
                    p.line(px+2, py+9, px+24, py+9, 13)
                    p.line(px+2, py+19, px+24, py+19, 13)
                    continue
                p.rectb(px, py, 27, 27, 13)
                rec = scent(b, s, cell)
                if rec:
                    for dot in range(max(1, 5-rec.distance)):
                        p.pset(px+3+dot*4, py+23, 10)
        for src in active(s):
            x, y = 26+src.pos[0]*28, 75+src.pos[1]*28
            color = 13 if src.kind == "old" else 10
            p.circ(x-5, y+3, 4, color)
            p.rect(x-9, y+3, 9, 4, color)
            p.text(x-11, y-11, str(src.life-(s.turn-src.birth)), color)
        x, y = 26+s.player[0]*28, 75+s.player[1]*28
        p.circ(x, y-6, 4, 12)
        p.rect(x-4, y-1, 9, 9, 12)
        p.rect(x+4, y+1, 5, 5, 4)
        x, y = 26+s.dog[0]*28, 75+s.dog[1]*28
        p.rect(x-7, y-2, 13, 9, 9)
        p.circ(x+5, y-5, 5, 9)
        p.tri(x+2, y-8, x+4, y-13, x+7, y-7, 4)
        p.pset(x+7, y-6, 1)
        intent = preview(b, s)
        mode = "CHASE" if intent.reason == "scent" else intent.reason.upper()
        p.text(12, 282, "DOG: "+mode+"  SOURCE: "+str(intent.source_id), 6)
        p.text(12, 266, self.message, 10)
        p.text(12, 298, "ARROWS MOVE  SPACE WAIT  Z PLACE", 7)
        p.text(12, 310, "R RESET   ESC QUIT", 6)

# 合成キー列を実updateへ渡す検証用。OSのキー操作や人の試遊ではない。
DEMO_ACTIONS = "Z"

def main(headless=False, screenshot=None, checkpoint=False, actions=None, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p
    if screenshot:
        screenshot = str(Path(screenshot).resolve())
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    state_json = str(Path(state_json).resolve()) if state_json else None
    app = App()
    plan = actions if actions is not None else DEMO_ACTIONS if checkpoint else ""
    inverse = {action: key for key, action in KEY_ACTION.items()}
    inverse.update({"X": "X", "!": "R"})
    if any(action not in inverse or inverse[action] not in CONTROL_KEYS for action in plan):
        raise ValueError("Unsupported action in --actions")
    frames = max(4, 2*len(plan)+3)
    p.init(332, 328, title="039 Watchdog Bakery / 2", fps=30, display_scale=2,
           headless=headless)

    class FrameInput:
        def __init__(self, pressed):
            self.pressed = pressed
        def __getattr__(self, name):
            if name.startswith("KEY_"):
                return name[4:]
            raise AttributeError(name)
        def btnp(self, key):
            return key in self.pressed
        def btn(self, key):
            return key in self.pressed

    def update():
        if headless:
            index = p.frame_count//2
            pressed = {inverse[plan[index]]} if p.frame_count%2 == 0 and index < len(plan) else set()
            app.update(FrameInput(pressed))
        else:
            app.update(p)

    def draw():
        app.draw(p)
        if headless and p.frame_count >= frames:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"stage": 2, "actions": plan,
                    "state": asdict(app.state), "message": app.message,
                    "evidence": "Real Pyxel headless drawing; synthetic update input; no OS input"},
                    ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Independent Pyxel tutorial / Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--actions")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.actions, args.state_json)
STEP 03 / 05

追跡と巡回を分ける

判断のモード、巡回の番号、匂いが消えたあとに巡回へ戻る。

匂いがあるときは scent(追跡)、ないときは patrol(巡回)という、別の Intent(犬の判断)を返します。巡回先の番号は Memory に保存します。今の目標に着いたら、番号を次へ進めます。巡回のための1歩も、今いるマスととなりのマスの中から選びます。プレイヤーに向かう道を探す処理はしません。匂いを追っている途中でも、巡回点に着けば番号を更新します。この点は元のゲームと同じです。この段階の犬は、まだパンを食べません。もぐもぐ、配達、勝ち負けもありません。

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

03_patrol.py / 注目する部分
    return Intent(o.patrol_step)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_patrol.py
macOS / Linux
.venv/bin/python 03_patrol.py
画面で見ることR のあとに Space を5回押すと、灰色のパンが期限切れになります。TURN 5、犬は(7,3)、DOG: PATROL(巡回)になります。匂いを消した状態なら、最初に待った手で、今の巡回点(7,2)を終えます。次に待った手で、(8,2)へ動きます。巡回点に着いたかを判定する手で、その先の巡回点まで2歩動くことはありません。
  • CHASE(追跡)と PATROL(巡回)が、別の分かれ道(分岐)になっている
  • 匂いが期限切れになったら、巡回に戻る
  • 巡回の番号を更新するときに、余分に動かない
つまずきやすいところ匂いが消えても、前のプレイヤーの位置を追い続ける記憶を足すと、元のゲームとは別のAIになってしまいます。犬がここで覚えるのは、巡回の番号です。匂いを追う行き先は、毎手、近くを嗅いだ結果(局所観測)から決めます。
少し変えてみよう

BOARD の patrol を ((8,2),(7,2),(8,4)) に変えてみましょう。匂い源を空にして、最初に待ったときに犬がどう動くかを予想します。犬はすぐに(8,2)へ動き、patrol_index は1になります。test_exercise_patrol_order_change と比べてみましょう。これは、元のゲームの巡回の順番を変えた実験です。

03_patrol.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(324行)
03_patrol.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/5。日本語の解説はLESSON.md。1ファイルで動作。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache

# マスの順序も仕様:現在、上、下、左、右。同点なら先を選ぶ。
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
ACTIONS = dict(zip("UDLR", DIRS))

@dataclass(frozen=True)
class Source:
    id: int
    pos: tuple
    birth: int
    life: int
    kind: str

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    dog: tuple
    patrol: tuple
    doors: tuple
    bread: int
    old: tuple = ()

# 原作Cと同じ配置。最初の灰色パンは別の店の残りで、所持数に含めない。
BOARD = Level("近い古いパン、遠い新しいパン", 11, 7,
              ((5, 2), (5, 3), (5, 4)), (4, 1), (7, 2),
              ((7, 2), (8, 2), (8, 4)), ((2, 1), (8, 1), (9, 5)), 5,
              (Source(0, (7, 4), -3, 8, "old"),))

@dataclass(frozen=True)
class Memory:
    patrol_index: int = 0

@dataclass(frozen=True)
class World:
    player: tuple
    dog: tuple
    bread: int
    sources: tuple = ()
    turn: int = 0
    next_id: int = 1
    memory: Memory = Memory()
    reason: str = "ready"
    picked: int = -1

def initial(b=BOARD):
    return World(b.start, b.dog, b.bread, sources=b.old)

def floor(b, p):
    return 0 < p[0] < b.width-1 and 0 < p[1] < b.height-1 and p not in b.walls

@lru_cache(None)
def distances(b, start):
    # 幅優先探索。壁を通らず、床の最短歩数を辞書へ記録する。
    distance = {start: 0}
    queue = deque([start])
    while queue:
        x, y = queue.popleft()
        for dx, dy in DIRS:
            cell = (x+dx, y+dy)
            if floor(b, cell) and cell not in distance:
                distance[cell] = distance[(x, y)]+1
                queue.append(cell)
    return distance

def active(s):
    # 年齢が寿命と同じになった手で消える。フレーム数は使わない。
    return tuple(src for src in s.sources if s.turn-src.birth < src.life)

@dataclass(frozen=True)
class Record:
    birth: int
    distance: int
    source_id: int

def scent(b, s, cell):
    records = []
    for src in active(s):
        # この段階には調査済み配達箱がない。
        distance = distances(b, src.pos).get(cell, 999)
        if distance <= 4:
            records.append(Record(src.birth, distance, src.id))
    # 各セルで、新しい誕生日→短い距離→小さいID。
    return min(records, key=lambda r: (-r.birth, r.distance, r.source_id), default=None)

@dataclass(frozen=True)
class Observation:
    cells: tuple
    records: tuple
    patrol_step: tuple

@dataclass(frozen=True)
class Intent:
    cell: tuple
    source_id: int = -1
    reason: str = "patrol"

def observation(b, s):
    cells = (s.dog,)+tuple((s.dog[0]+dx, s.dog[1]+dy) for dx, dy in DIRS
                          if floor(b, (s.dog[0]+dx, s.dog[1]+dy)))
    target = b.patrol[s.memory.patrol_index]
    distance = distances(b, target)
    patrol_step = min(cells, key=lambda cell: distance.get(cell, 999))
    return Observation(cells, tuple(scent(b, s, cell) for cell in cells), patrol_step)

def policy(o):
    # 追跡と巡回を分ける。追跡先は観測にある現在セル・隣接セルのみ。
    found = [(index, r) for index, r in enumerate(o.records) if r is not None]
    if found:
        index, r = min(found, key=lambda item: (-item[1].birth, item[1].distance,
                                               item[0], item[1].source_id))
        return Intent(o.cells[index], r.source_id, "scent")
    return Intent(o.patrol_step)

def preview(b, s):
    return policy(observation(b, s))

def step(b, s, action):
    player, bread, sources, next_id = s.player, s.bread, s.sources, s.next_id
    turn = s.turn+1
    reason = "walk"
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        player = (player[0]+dx, player[1]+dy)
        if not floor(b, player):
            return s  # 壁への入力は時刻を進めない。
    elif action == "Z":
        if bread <= 0:
            return s
        bread -= 1
        sources += (Source(next_id, player, turn, 8, "bait"),)
        next_id += 1
        reason = "bait"
    elif action == "W":
        reason = "wait"
    else:
        return s
    s = replace(s, player=player, bread=bread, sources=sources, next_id=next_id,
                turn=turn, reason=reason)
    s = replace(s, sources=active(s))
    intent = preview(b, s)
    memory = s.memory
    if intent.cell == b.patrol[memory.patrol_index]:
        memory = replace(memory, patrol_index=(memory.patrol_index+1) % len(b.patrol))
    return replace(s, dog=intent.cell, picked=intent.source_id, memory=memory)

KEY_ACTION = {"UP": "U", "DOWN": "D", "LEFT": "L", "RIGHT": "R", "SPACE": "W", "Z": "Z"}
CONTROL_KEYS = tuple(KEY_ACTION)+("R",)

class App:
    def __init__(self):
        self.locked = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.message = "READY"

    def control(self, pressed, held):
        # R直後は、全操作キーを離したフレームを1回通ってから再受付。
        if self.locked:
            if not held.intersection(CONTROL_KEYS):
                self.locked = False
            return
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        chosen = [KEY_ACTION[key] for key in KEY_ACTION if key in pressed]
        action_held = sum(key in held for key in KEY_ACTION)
        # 新しく押したキーだけでなく、押し続けている行動キーも数える。
        if len(chosen) == 1 and action_held == 1:
            before = self.state
            self.state = step(BOARD, self.state, chosen[0])
            self.message = self.state.reason.upper() if self.state != before else "BLOCKED / NO TURN"

    def update(self, p):
        # 実Pyxelもテストも、このポーリング入口とcontrolを通る。
        pressed = {key for key in CONTROL_KEYS if p.btnp(getattr(p, "KEY_"+key))}
        held = {key for key in CONTROL_KEYS if p.btn(getattr(p, "KEY_"+key))}
        self.control(pressed, held)

    def draw(self, p):
        s, b = self.state, BOARD
        p.cls(1)
        p.text(12, 9, "WATCHDOG BAKERY / STAGE 3", 10)
        p.text(12, 23, "CHASE WHEN SCENT / OTHERWISE PATROL", 6)
        p.text(12, 38, "TURN "+str(s.turn)+"   BREAD "+str(s.bread), 7)
        p.text(180, 38, "PRACTICE / NO ENDING", 13)
        for y in range(b.height):
            for x in range(b.width):
                cell = (x, y)
                px, py = 12+x*28, 61+y*28
                if not floor(b, cell):
                    p.rect(px, py, 27, 27, 5)
                    p.line(px+2, py+9, px+24, py+9, 13)
                    p.line(px+2, py+19, px+24, py+19, 13)
                    continue
                p.rectb(px, py, 27, 27, 13)
                rec = scent(b, s, cell)
                if rec:
                    for dot in range(max(1, 5-rec.distance)):
                        p.pset(px+3+dot*4, py+23, 10)
        for src in active(s):
            x, y = 26+src.pos[0]*28, 75+src.pos[1]*28
            color = 13 if src.kind == "old" else 10
            p.circ(x-5, y+3, 4, color)
            p.rect(x-9, y+3, 9, 4, color)
            p.text(x-11, y-11, str(src.life-(s.turn-src.birth)), color)
        x, y = 26+s.player[0]*28, 75+s.player[1]*28
        p.circ(x, y-6, 4, 12)
        p.rect(x-4, y-1, 9, 9, 12)
        p.rect(x+4, y+1, 5, 5, 4)
        x, y = 26+s.dog[0]*28, 75+s.dog[1]*28
        p.rect(x-7, y-2, 13, 9, 9)
        p.circ(x+5, y-5, 5, 9)
        p.tri(x+2, y-8, x+4, y-13, x+7, y-7, 4)
        p.pset(x+7, y-6, 1)
        intent = preview(b, s)
        mode = "CHASE" if intent.reason == "scent" else intent.reason.upper()
        p.text(12, 282, "DOG: "+mode+"  SOURCE: "+str(intent.source_id), 6)
        p.text(12, 266, self.message, 10)
        p.text(12, 298, "ARROWS MOVE  SPACE WAIT  Z PLACE", 7)
        p.text(12, 310, "R RESET   ESC QUIT", 6)

# 合成キー列を実updateへ渡す検証用。OSのキー操作や人の試遊ではない。
DEMO_ACTIONS = "WWWWW"

def main(headless=False, screenshot=None, checkpoint=False, actions=None, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p
    if screenshot:
        screenshot = str(Path(screenshot).resolve())
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    state_json = str(Path(state_json).resolve()) if state_json else None
    app = App()
    plan = actions if actions is not None else DEMO_ACTIONS if checkpoint else ""
    inverse = {action: key for key, action in KEY_ACTION.items()}
    inverse.update({"X": "X", "!": "R"})
    if any(action not in inverse or inverse[action] not in CONTROL_KEYS for action in plan):
        raise ValueError("Unsupported action in --actions")
    frames = max(4, 2*len(plan)+3)
    p.init(332, 328, title="039 Watchdog Bakery / 3", fps=30, display_scale=2,
           headless=headless)

    class FrameInput:
        def __init__(self, pressed):
            self.pressed = pressed
        def __getattr__(self, name):
            if name.startswith("KEY_"):
                return name[4:]
            raise AttributeError(name)
        def btnp(self, key):
            return key in self.pressed
        def btn(self, key):
            return key in self.pressed

    def update():
        if headless:
            index = p.frame_count//2
            pressed = {inverse[plan[index]]} if p.frame_count%2 == 0 and index < len(plan) else set()
            app.update(FrameInput(pressed))
        else:
            app.update(p)

    def draw():
        app.draw(p)
        if headless and p.frame_count >= frames:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"stage": 3, "actions": plan,
                    "state": asdict(app.state), "message": app.message,
                    "evidence": "Real Pyxel headless drawing; synthetic update input; no OS input"},
                    ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Independent Pyxel tutorial / Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--actions")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.actions, args.state_json)
STEP 04 / 05

三軒への配達とパンの収支を完成させる

行動の順番、資源、守られた配達箱、終わる条件。

ここで、元のゲームの面Cのルールをそろえます。まだ配達していない家の玄関で Z を押すと、配達済みのビット(配達したかどうかを覚える印)を立てて、6手のあいだ残る匂いを作ります。それ以外の Z は、8手のあいだ残る餌です。犬が動いたあとは、「食べられるパン」→「プレイヤーとの接触」→「配達箱の調査」の順に調べて、1つだけ処理します。食べたり調べたりしたあとは、2手のあいだ「もぐもぐ」になります。配達箱は消えません。調べ終えた箱のIDは、そのあと犬が嗅ぐ候補から外します。三軒すべてに配達したら成功です。残りの配達数より持っているパンが少なくなったら、パン不足で終わります。

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

04_delivery.py / 注目する部分
    if not o.chew:  # 1→0になる手も、まだ食事中。追加のパンを取らない。

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_delivery.py
macOS / Linux
.venv/bin/python 04_delivery.py
画面で見ることZ、左、左、Z、右を6回、Z、下を4回、右、Z と押すと、TURN 17、DELIVERED 3/3、WON(成功)になり、パンが1個残ります。配達せずに最初から Z を3回押すと、持っているパンは2個で、まだ配達していない家が3軒あるので SHORT(パン不足)です。玄関に立つだけでは配達されません。
  • まだ配達していない家の玄関でだけ、Z が配達になる
  • 食べる・渡す・調べる処理で、パンを二重に減らさない
  • 成功やパン不足になると行動が止まり、R でやり直せる
つまずきやすいところchew(もぐもぐの残り手数)を先に0へ減らしてから接触を調べると、1→0になる手でも、余分にパンを取ってしまいます。食べる処理をするかどうかは、観測したときの o.chew が0だったかで決めます。また、配達済みのビットは、匂いが期限切れになっても消してはいけません。
少し変えてみよう

R のあと、左、左、Z、右を6回、Z、右、下を4回、Z という15手の道順を試してみましょう。餌を置く17手の例よりパンが1個多く、2個残して成功します。test_exercise_save_bread_solution で、残りのパンの数と gifts=0 を確かめましょう。「餌はいつも必要」という思い込みを外すための練習です。

04_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(374行)
04_delivery.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/5。日本語の解説はLESSON.md。1ファイルで動作。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache

# マスの順序も仕様:現在、上、下、左、右。同点なら先を選ぶ。
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
ACTIONS = dict(zip("UDLR", DIRS))

@dataclass(frozen=True)
class Source:
    id: int
    pos: tuple
    birth: int
    life: int
    kind: str

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    dog: tuple
    patrol: tuple
    doors: tuple
    bread: int
    old: tuple = ()

# 原作Cと同じ配置。最初の灰色パンは別の店の残りで、所持数に含めない。
BOARD = Level("近い古いパン、遠い新しいパン", 11, 7,
              ((5, 2), (5, 3), (5, 4)), (4, 1), (7, 2),
              ((7, 2), (8, 2), (8, 4)), ((2, 1), (8, 1), (9, 5)), 5,
              (Source(0, (7, 4), -3, 8, "old"),))

@dataclass(frozen=True)
class Memory:
    investigated: tuple = ()
    patrol_index: int = 0

@dataclass(frozen=True)
class World:
    player: tuple
    dog: tuple
    bread: int
    delivered: int = 0
    sources: tuple = ()
    turn: int = 0
    next_id: int = 1
    chew: int = 0
    memory: Memory = Memory()
    status: str = "play"
    reason: str = "ready"
    picked: int = -1
    bait_used: int = 0
    gifts: int = 0

def initial(b=BOARD):
    return World(b.start, b.dog, b.bread, sources=b.old)

def floor(b, p):
    return 0 < p[0] < b.width-1 and 0 < p[1] < b.height-1 and p not in b.walls

@lru_cache(None)
def distances(b, start):
    # 幅優先探索。壁を通らず、床の最短歩数を辞書へ記録する。
    distance = {start: 0}
    queue = deque([start])
    while queue:
        x, y = queue.popleft()
        for dx, dy in DIRS:
            cell = (x+dx, y+dy)
            if floor(b, cell) and cell not in distance:
                distance[cell] = distance[(x, y)]+1
                queue.append(cell)
    return distance

def active(s):
    # 年齢が寿命と同じになった手で消える。フレーム数は使わない。
    return tuple(src for src in s.sources if s.turn-src.birth < src.life)

@dataclass(frozen=True)
class Record:
    birth: int
    distance: int
    source_id: int

def scent(b, s, cell):
    records = []
    for src in active(s):
        if src.id in s.memory.investigated:
            continue
        distance = distances(b, src.pos).get(cell, 999)
        if distance <= 4:
            records.append(Record(src.birth, distance, src.id))
    # 各セルで、新しい誕生日→短い距離→小さいID。
    return min(records, key=lambda r: (-r.birth, r.distance, r.source_id), default=None)

@dataclass(frozen=True)
class Observation:
    cells: tuple
    records: tuple
    patrol_step: tuple
    chew: int

@dataclass(frozen=True)
class Intent:
    cell: tuple
    source_id: int = -1
    reason: str = "patrol"

def observation(b, s):
    cells = (s.dog,)+tuple((s.dog[0]+dx, s.dog[1]+dy) for dx, dy in DIRS
                          if floor(b, (s.dog[0]+dx, s.dog[1]+dy)))
    target = b.patrol[s.memory.patrol_index]
    distance = distances(b, target)
    patrol_step = min(cells, key=lambda cell: distance.get(cell, 999))
    return Observation(cells, tuple(scent(b, s, cell) for cell in cells), patrol_step, s.chew)

def policy(o):
    if o.chew:
        return Intent(o.cells[0], reason="chew")
    # 追跡と巡回を分ける。追跡先は観測にある現在セル・隣接セルのみ。
    found = [(index, r) for index, r in enumerate(o.records) if r is not None]
    if found:
        index, r = min(found, key=lambda item: (-item[1].birth, item[1].distance,
                                               item[0], item[1].source_id))
        return Intent(o.cells[index], r.source_id, "scent")
    return Intent(o.patrol_step)

def preview(b, s):
    return policy(observation(b, s))

def step(b, s, action):
    if s.status != "play":
        return s
    player, turn, bread = s.player, s.turn+1, s.bread
    sources, delivered, next_id = s.sources, s.delivered, s.next_id
    bait_used, reason = s.bait_used, "walk"
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        player = (player[0]+dx, player[1]+dy)
        if not floor(b, player):
            return s
    elif action == "Z":
        if bread <= 0:
            return s
        delivery = player in b.doors and not delivered & (1 << b.doors.index(player))
        bread -= 1
        if delivery:
            delivered |= 1 << b.doors.index(player)
            reason = "delivery"
        else:
            bait_used += 1
            reason = "bait"
        sources += (Source(next_id, player, turn, 6 if delivery else 8,
                           "delivery" if delivery else "bait"),)
        next_id += 1
    elif action == "W":
        reason = "wait"
    else:
        return s
    s = replace(s, player=player, turn=turn, bread=bread, delivered=delivered,
                sources=sources, next_id=next_id, bait_used=bait_used, reason=reason)
    # 時刻更新→期限切れを除去→旧犬位置で観測→判断→1歩移動。
    s = replace(s, sources=active(s))
    o = observation(b, s)
    intent = policy(o)
    dog, chew, memory, gifts = intent.cell, max(0, s.chew-1), s.memory, s.gifts
    if not o.chew:  # 1→0になる手も、まだ食事中。追加のパンを取らない。
        here = [src for src in s.sources if src.pos == dog and src.id not in memory.investigated]
        edible = [src for src in here if src.kind != "delivery"]
        if edible:
            src = min(edible, key=lambda src: (-src.birth, src.id))
            s = replace(s, sources=tuple(other for other in s.sources if other.id != src.id))
            chew, reason = 2, "eat"
        elif dog == player and bread > 0:
            bread -= 1
            gifts += 1
            chew, reason = 2, "contact"
        elif here:
            src = min(here, key=lambda src: (-src.birth, src.id))
            memory = replace(memory, investigated=memory.investigated+(src.id,))
            chew, reason = 2, "inspect"
        if dog == b.patrol[memory.patrol_index]:
            memory = replace(memory, patrol_index=(memory.patrol_index+1) % len(b.patrol))
    status = "won" if delivered == 7 else "short" if bread < 3-delivered.bit_count() else "play"
    if status == "won":
        reason = "complete"
    elif status == "short":
        reason = "short"
    return replace(s, dog=dog, bread=bread, chew=chew, memory=memory, status=status,
                   reason=reason, picked=intent.source_id, gifts=gifts)

KEY_ACTION = {"UP": "U", "DOWN": "D", "LEFT": "L", "RIGHT": "R", "SPACE": "W", "Z": "Z"}
CONTROL_KEYS = tuple(KEY_ACTION)+("R",)

class App:
    def __init__(self):
        self.locked = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.message = "READY"

    def control(self, pressed, held):
        # R直後は、全操作キーを離したフレームを1回通ってから再受付。
        if self.locked:
            if not held.intersection(CONTROL_KEYS):
                self.locked = False
            return
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        if self.state.status != "play":
            return
        chosen = [KEY_ACTION[key] for key in KEY_ACTION if key in pressed]
        action_held = sum(key in held for key in KEY_ACTION)
        # 新しく押したキーだけでなく、押し続けている行動キーも数える。
        if len(chosen) == 1 and action_held == 1:
            before = self.state
            self.state = step(BOARD, self.state, chosen[0])
            self.message = self.state.reason.upper() if self.state != before else "BLOCKED / NO TURN"

    def update(self, p):
        # 実Pyxelもテストも、このポーリング入口とcontrolを通る。
        pressed = {key for key in CONTROL_KEYS if p.btnp(getattr(p, "KEY_"+key))}
        held = {key for key in CONTROL_KEYS if p.btn(getattr(p, "KEY_"+key))}
        self.control(pressed, held)

    def draw(self, p):
        s, b = self.state, BOARD
        p.cls(1)
        p.text(12, 9, "WATCHDOG BAKERY / STAGE 4", 10)
        p.text(12, 23, "THREE DOORS / PROTECTED DELIVERY", 6)
        p.text(12, 38, "TURN "+str(s.turn)+"   BREAD "+str(s.bread), 7)
        p.text(164, 38, "DELIVERED "+str(s.delivered.bit_count())+"/3  "+s.status.upper(), 11)
        for y in range(b.height):
            for x in range(b.width):
                cell = (x, y)
                px, py = 12+x*28, 61+y*28
                if not floor(b, cell):
                    p.rect(px, py, 27, 27, 5)
                    p.line(px+2, py+9, px+24, py+9, 13)
                    p.line(px+2, py+19, px+24, py+19, 13)
                    continue
                p.rectb(px, py, 27, 27, 13)
                rec = scent(b, s, cell)
                if rec:
                    for dot in range(max(1, 5-rec.distance)):
                        p.pset(px+3+dot*4, py+23, 10)
                if cell in b.doors:
                    bit = 1 << b.doors.index(cell)
                    color = 11 if s.delivered & bit else 9
                    p.rect(px+6, py+5, 15, 14, color)
                    p.text(px+12, py+7, str(b.doors.index(cell)+1), 1)
                    p.line(px+3, py+20, px+24, py+20, 7)
        for src in active(s):
            x, y = 26+src.pos[0]*28, 75+src.pos[1]*28
            color = 13 if src.kind == "old" else 10
            p.circ(x-5, y+3, 4, color)
            p.rect(x-9, y+3, 9, 4, color)
            p.text(x-11, y-11, str(src.life-(s.turn-src.birth)), color)
        x, y = 26+s.player[0]*28, 75+s.player[1]*28
        p.circ(x, y-6, 4, 12)
        p.rect(x-4, y-1, 9, 9, 12)
        p.rect(x+4, y+1, 5, 5, 4)
        x, y = 26+s.dog[0]*28, 75+s.dog[1]*28
        p.rect(x-7, y-2, 13, 9, 9)
        p.circ(x+5, y-5, 5, 9)
        p.tri(x+2, y-8, x+4, y-13, x+7, y-7, 4)
        p.pset(x+7, y-6, 1)
        intent = preview(b, s)
        mode = "CHASE" if intent.reason == "scent" else intent.reason.upper()
        p.text(12, 282, "DOG: "+mode+"  SOURCE: "+str(intent.source_id), 6)
        p.text(12, 266, self.message, 10)
        p.text(12, 298, "ARROWS MOVE  SPACE WAIT  Z DELIVER/BAIT", 7)
        p.text(12, 310, "R RESET   ESC QUIT", 6)

# 合成キー列を実updateへ渡す検証用。OSのキー操作や人の試遊ではない。
DEMO_ACTIONS = "ZLLZRRRRRRZDDDDRZ"

def main(headless=False, screenshot=None, checkpoint=False, actions=None, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p
    if screenshot:
        screenshot = str(Path(screenshot).resolve())
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    state_json = str(Path(state_json).resolve()) if state_json else None
    app = App()
    plan = actions if actions is not None else DEMO_ACTIONS if checkpoint else ""
    inverse = {action: key for key, action in KEY_ACTION.items()}
    inverse.update({"X": "X", "!": "R"})
    if any(action not in inverse or inverse[action] not in CONTROL_KEYS for action in plan):
        raise ValueError("Unsupported action in --actions")
    frames = max(4, 2*len(plan)+3)
    p.init(332, 328, title="039 Watchdog Bakery / 4", fps=30, display_scale=2,
           headless=headless)

    class FrameInput:
        def __init__(self, pressed):
            self.pressed = pressed
        def __getattr__(self, name):
            if name.startswith("KEY_"):
                return name[4:]
            raise AttributeError(name)
        def btnp(self, key):
            return key in self.pressed
        def btn(self, key):
            return key in self.pressed

    def update():
        if headless:
            index = p.frame_count//2
            pressed = {inverse[plan[index]]} if p.frame_count%2 == 0 and index < len(plan) else set()
            app.update(FrameInput(pressed))
        else:
            app.update(p)

    def draw():
        app.draw(p)
        if headless and p.frame_count >= frames:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"stage": 4, "actions": plan,
                    "state": asdict(app.state), "message": app.message,
                    "evidence": "Real Pyxel headless drawing; synthetic update input; no OS input"},
                    ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Independent Pyxel tutorial / Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--actions")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.actions, args.state_json)
STEP 05 / 05

匂いの年齢と犬の記憶まで戻す

書き換えないデータ、受け付けた手の履歴、入力の優先順位。

World・Source・Memory を、書き換えない値として持ちます。手を受け付けたら、その前の World を Timeline.history(履歴)に保存します。X を押すと、座標だけでなく、手数、匂い源と birth、調べ終えたID、巡回の番号、もぐもぐ、パン、配達、勝ち負けも戻します。壁に向かう入力や、受け付けない入力は、履歴に追加しません。R は、最初の状態と空の履歴に戻します。R の直後は、操作キーをすべて離すまで、次の入力を受け付けません。X は、元のゲームと同じく、ふつうの行動より先に処理されます。成功やパン不足のあとでも使えます。

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

05_undo.py / 注目する部分
    return Timeline(state, timeline.history+(timeline.state,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること攻略例の手順で WON にしてから X を押すと、TURN 16、配達2/3の PLAY(遊んでいる途中)に戻ります。そこで Z を押すと、同じ WON をもう一度再現できます。R のあとに X を押しても、前の挑戦には戻りません。右を押し続けたまま、新しく Z を押すと、行動は受け付けられません。X と矢印を新しく同時に押したときは、X だけを処理します。
  • 匂いの年齢と、調べ終えたIDも元に戻る
  • 受け付けない手は、Undo(元に戻す)の対象にならない
  • 押し続けているキーと、X・R の優先順位を説明できる
つまずきやすいところ書き換えられる辞書やリストを、そのまま履歴に保存すると、過去の状態も一緒に変わってしまいます。frozen=True とタプルで過去を固定し、replace で新しい状態を作ります。また、キーが複数押されているかを調べるときは、新しく押したキーだけでなく、押し続けている行動キーも数えます。
少し変えてみよう

Z、左、左、Z、右まで進めて、その状態を書き留めます。次に X、右と押して、さっきの状態と完全に同じになることを確かめます。続いて R を押し、すべてのキーを離してから X を押します。匂いと履歴が戻ってこないことを確かめましょう。対応するテストは test_exercise_undo_redo_equality と test_undo_after_terminal_and_reset_clears_history です。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(399行)
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/5。日本語の解説はLESSON.md。1ファイルで動作。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache

# マスの順序も仕様:現在、上、下、左、右。同点なら先を選ぶ。
DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))
ACTIONS = dict(zip("UDLR", DIRS))

@dataclass(frozen=True)
class Source:
    id: int
    pos: tuple
    birth: int
    life: int
    kind: str

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    dog: tuple
    patrol: tuple
    doors: tuple
    bread: int
    old: tuple = ()

# 原作Cと同じ配置。最初の灰色パンは別の店の残りで、所持数に含めない。
BOARD = Level("近い古いパン、遠い新しいパン", 11, 7,
              ((5, 2), (5, 3), (5, 4)), (4, 1), (7, 2),
              ((7, 2), (8, 2), (8, 4)), ((2, 1), (8, 1), (9, 5)), 5,
              (Source(0, (7, 4), -3, 8, "old"),))

@dataclass(frozen=True)
class Memory:
    investigated: tuple = ()
    patrol_index: int = 0

@dataclass(frozen=True)
class World:
    player: tuple
    dog: tuple
    bread: int
    delivered: int = 0
    sources: tuple = ()
    turn: int = 0
    next_id: int = 1
    chew: int = 0
    memory: Memory = Memory()
    status: str = "play"
    reason: str = "ready"
    picked: int = -1
    bait_used: int = 0
    gifts: int = 0

def initial(b=BOARD):
    return World(b.start, b.dog, b.bread, sources=b.old)

def floor(b, p):
    return 0 < p[0] < b.width-1 and 0 < p[1] < b.height-1 and p not in b.walls

@lru_cache(None)
def distances(b, start):
    # 幅優先探索。壁を通らず、床の最短歩数を辞書へ記録する。
    distance = {start: 0}
    queue = deque([start])
    while queue:
        x, y = queue.popleft()
        for dx, dy in DIRS:
            cell = (x+dx, y+dy)
            if floor(b, cell) and cell not in distance:
                distance[cell] = distance[(x, y)]+1
                queue.append(cell)
    return distance

def active(s):
    # 年齢が寿命と同じになった手で消える。フレーム数は使わない。
    return tuple(src for src in s.sources if s.turn-src.birth < src.life)

@dataclass(frozen=True)
class Record:
    birth: int
    distance: int
    source_id: int

def scent(b, s, cell):
    records = []
    for src in active(s):
        if src.id in s.memory.investigated:
            continue
        distance = distances(b, src.pos).get(cell, 999)
        if distance <= 4:
            records.append(Record(src.birth, distance, src.id))
    # 各セルで、新しい誕生日→短い距離→小さいID。
    return min(records, key=lambda r: (-r.birth, r.distance, r.source_id), default=None)

@dataclass(frozen=True)
class Observation:
    cells: tuple
    records: tuple
    patrol_step: tuple
    chew: int

@dataclass(frozen=True)
class Intent:
    cell: tuple
    source_id: int = -1
    reason: str = "patrol"

def observation(b, s):
    cells = (s.dog,)+tuple((s.dog[0]+dx, s.dog[1]+dy) for dx, dy in DIRS
                          if floor(b, (s.dog[0]+dx, s.dog[1]+dy)))
    target = b.patrol[s.memory.patrol_index]
    distance = distances(b, target)
    patrol_step = min(cells, key=lambda cell: distance.get(cell, 999))
    return Observation(cells, tuple(scent(b, s, cell) for cell in cells), patrol_step, s.chew)

def policy(o):
    if o.chew:
        return Intent(o.cells[0], reason="chew")
    # 追跡と巡回を分ける。追跡先は観測にある現在セル・隣接セルのみ。
    found = [(index, r) for index, r in enumerate(o.records) if r is not None]
    if found:
        index, r = min(found, key=lambda item: (-item[1].birth, item[1].distance,
                                               item[0], item[1].source_id))
        return Intent(o.cells[index], r.source_id, "scent")
    return Intent(o.patrol_step)

def preview(b, s):
    return policy(observation(b, s))

def step(b, s, action):
    if s.status != "play":
        return s
    player, turn, bread = s.player, s.turn+1, s.bread
    sources, delivered, next_id = s.sources, s.delivered, s.next_id
    bait_used, reason = s.bait_used, "walk"
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        player = (player[0]+dx, player[1]+dy)
        if not floor(b, player):
            return s
    elif action == "Z":
        if bread <= 0:
            return s
        delivery = player in b.doors and not delivered & (1 << b.doors.index(player))
        bread -= 1
        if delivery:
            delivered |= 1 << b.doors.index(player)
            reason = "delivery"
        else:
            bait_used += 1
            reason = "bait"
        sources += (Source(next_id, player, turn, 6 if delivery else 8,
                           "delivery" if delivery else "bait"),)
        next_id += 1
    elif action == "W":
        reason = "wait"
    else:
        return s
    s = replace(s, player=player, turn=turn, bread=bread, delivered=delivered,
                sources=sources, next_id=next_id, bait_used=bait_used, reason=reason)
    # 時刻更新→期限切れを除去→旧犬位置で観測→判断→1歩移動。
    s = replace(s, sources=active(s))
    o = observation(b, s)
    intent = policy(o)
    dog, chew, memory, gifts = intent.cell, max(0, s.chew-1), s.memory, s.gifts
    if not o.chew:  # 1→0になる手も、まだ食事中。追加のパンを取らない。
        here = [src for src in s.sources if src.pos == dog and src.id not in memory.investigated]
        edible = [src for src in here if src.kind != "delivery"]
        if edible:
            src = min(edible, key=lambda src: (-src.birth, src.id))
            s = replace(s, sources=tuple(other for other in s.sources if other.id != src.id))
            chew, reason = 2, "eat"
        elif dog == player and bread > 0:
            bread -= 1
            gifts += 1
            chew, reason = 2, "contact"
        elif here:
            src = min(here, key=lambda src: (-src.birth, src.id))
            memory = replace(memory, investigated=memory.investigated+(src.id,))
            chew, reason = 2, "inspect"
        if dog == b.patrol[memory.patrol_index]:
            memory = replace(memory, patrol_index=(memory.patrol_index+1) % len(b.patrol))
    status = "won" if delivered == 7 else "short" if bread < 3-delivered.bit_count() else "play"
    if status == "won":
        reason = "complete"
    elif status == "short":
        reason = "short"
    return replace(s, dog=dog, bread=bread, chew=chew, memory=memory, status=status,
                   reason=reason, picked=intent.source_id, gifts=gifts)

@dataclass(frozen=True)
class Timeline:
    state: World
    history: tuple = ()

def apply(b, timeline, action):
    state = step(b, timeline.state, action)
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.history+(timeline.state,))

def undo(timeline):
    if not timeline.history:
        return timeline
    return Timeline(timeline.history[-1], timeline.history[:-1])

KEY_ACTION = {"UP": "U", "DOWN": "D", "LEFT": "L", "RIGHT": "R", "SPACE": "W", "Z": "Z"}
CONTROL_KEYS = tuple(KEY_ACTION)+("R", "X")

class App:
    def __init__(self):
        self.locked = False
        self.reset()

    def reset(self):
        self.timeline = Timeline(initial())
        self.message = "READY"

    @property
    def state(self):
        return self.timeline.state

    def control(self, pressed, held):
        # R直後は、全操作キーを離したフレームを1回通ってから再受付。
        if self.locked:
            if not held.intersection(CONTROL_KEYS):
                self.locked = False
            return
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        # 原作通りXは行動キーより優先。X+矢印も一手戻すだけ。
        if "X" in pressed:
            self.timeline = undo(self.timeline)
            self.message = "UNDO / ALL STATE RESTORED"
            return
        if self.state.status != "play":
            return
        chosen = [KEY_ACTION[key] for key in KEY_ACTION if key in pressed]
        action_held = sum(key in held for key in KEY_ACTION)
        # 新しく押したキーだけでなく、押し続けている行動キーも数える。
        if len(chosen) == 1 and action_held == 1:
            before = self.state
            self.timeline = apply(BOARD, self.timeline, chosen[0])
            self.message = self.state.reason.upper() if self.state != before else "BLOCKED / NO TURN"

    def update(self, p):
        # 実Pyxelもテストも、このポーリング入口とcontrolを通る。
        pressed = {key for key in CONTROL_KEYS if p.btnp(getattr(p, "KEY_"+key))}
        held = {key for key in CONTROL_KEYS if p.btn(getattr(p, "KEY_"+key))}
        self.control(pressed, held)

    def draw(self, p):
        s, b = self.state, BOARD
        p.cls(1)
        p.text(12, 9, "WATCHDOG BAKERY / STAGE 5", 10)
        p.text(12, 23, "FULL STATE HISTORY / UNDO", 6)
        p.text(12, 38, "TURN "+str(s.turn)+"   BREAD "+str(s.bread), 7)
        p.text(164, 38, "DELIVERED "+str(s.delivered.bit_count())+"/3  "+s.status.upper(), 11)
        for y in range(b.height):
            for x in range(b.width):
                cell = (x, y)
                px, py = 12+x*28, 61+y*28
                if not floor(b, cell):
                    p.rect(px, py, 27, 27, 5)
                    p.line(px+2, py+9, px+24, py+9, 13)
                    p.line(px+2, py+19, px+24, py+19, 13)
                    continue
                p.rectb(px, py, 27, 27, 13)
                rec = scent(b, s, cell)
                if rec:
                    for dot in range(max(1, 5-rec.distance)):
                        p.pset(px+3+dot*4, py+23, 10)
                if cell in b.doors:
                    bit = 1 << b.doors.index(cell)
                    color = 11 if s.delivered & bit else 9
                    p.rect(px+6, py+5, 15, 14, color)
                    p.text(px+12, py+7, str(b.doors.index(cell)+1), 1)
                    p.line(px+3, py+20, px+24, py+20, 7)
        for src in active(s):
            x, y = 26+src.pos[0]*28, 75+src.pos[1]*28
            color = 13 if src.kind == "old" else 10
            p.circ(x-5, y+3, 4, color)
            p.rect(x-9, y+3, 9, 4, color)
            p.text(x-11, y-11, str(src.life-(s.turn-src.birth)), color)
        x, y = 26+s.player[0]*28, 75+s.player[1]*28
        p.circ(x, y-6, 4, 12)
        p.rect(x-4, y-1, 9, 9, 12)
        p.rect(x+4, y+1, 5, 5, 4)
        x, y = 26+s.dog[0]*28, 75+s.dog[1]*28
        p.rect(x-7, y-2, 13, 9, 9)
        p.circ(x+5, y-5, 5, 9)
        p.tri(x+2, y-8, x+4, y-13, x+7, y-7, 4)
        p.pset(x+7, y-6, 1)
        intent = preview(b, s)
        mode = "CHASE" if intent.reason == "scent" else intent.reason.upper()
        p.text(12, 282, "DOG: "+mode+"  SOURCE: "+str(intent.source_id), 6)
        p.text(12, 266, self.message, 10)
        p.text(12, 298, "ARROWS MOVE  SPACE WAIT  Z DELIVER/BAIT", 7)
        p.text(12, 310, "R RESET   X UNDO   ESC QUIT", 6)

# 合成キー列を実updateへ渡す検証用。OSのキー操作や人の試遊ではない。
DEMO_ACTIONS = "ZLLZRRRRRRZDDDDRZ"

def main(headless=False, screenshot=None, checkpoint=False, actions=None, state_json=None):
    from pathlib import Path
    import json
    import pyxel as p
    if screenshot:
        screenshot = str(Path(screenshot).resolve())
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    state_json = str(Path(state_json).resolve()) if state_json else None
    app = App()
    plan = actions if actions is not None else DEMO_ACTIONS if checkpoint else ""
    inverse = {action: key for key, action in KEY_ACTION.items()}
    inverse.update({"X": "X", "!": "R"})
    if any(action not in inverse or inverse[action] not in CONTROL_KEYS for action in plan):
        raise ValueError("Unsupported action in --actions")
    frames = max(4, 2*len(plan)+3)
    p.init(332, 328, title="039 Watchdog Bakery / 5", fps=30, display_scale=2,
           headless=headless)

    class FrameInput:
        def __init__(self, pressed):
            self.pressed = pressed
        def __getattr__(self, name):
            if name.startswith("KEY_"):
                return name[4:]
            raise AttributeError(name)
        def btnp(self, key):
            return key in self.pressed
        def btn(self, key):
            return key in self.pressed

    def update():
        if headless:
            index = p.frame_count//2
            pressed = {inverse[plan[index]]} if p.frame_count%2 == 0 and index < len(plan) else set()
            app.update(FrameInput(pressed))
        else:
            app.update(p)

    def draw():
        app.draw(p)
        if headless and p.frame_count >= frames:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"stage": 5, "actions": plan,
                    "state": asdict(app.state), "message": app.message,
                    "evidence": "Real Pyxel headless drawing; synthetic update input; no OS input"},
                    ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
    p.run(update, draw)
    return app

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Independent Pyxel tutorial / Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--actions")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.actions, args.state_json)
AFTER STEP 05

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

元のゲームには3つの状況(面)があります。犬は、今いるマスと、歩ける上下左右のマスで、床距離4以内の匂いを嗅ぎます。1つのマスの中では、birth の大きい順(新しい順)→距離→IDの順に比べます。マスどうしでは、birth の大きい順→距離→マスの順番→IDの順に比べます。匂いが残るのは、餌が8手、配達箱が6手です。近くに匂いがなければ巡回します。食事・接触・調査のあとは、2手のあいだもぐもぐします。配達したパンは奪われません。調べた箱の匂いは候補から外します。三軒に1個ずつ届ければ成功です。残りの必要数よりパンが少なければ、そこで終わります。X で、すべての状態を戻せます。

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

比べる点5ステップの教材元の完成版
対象の面元のゲームの面Cの座標、最初のパン5個、古いパン、3つの巡回点をそのまま使う1面。自作の地図とは呼ばない。A「一個を角へ」、B「配達も新しい匂い」、C「近い古いパン、遠い新しいパン」の3面。
01で簡単にしたところ移動、Z の餌、8手の寿命だけ。犬、匂いの広がり、配達、勝ち負けはない。Level のうち、まだ使わないデータは、あとの段階で使う。犬の観測・巡回・食事と、配達とパンの数による勝ち負けが、最初から入っている。
02と03で簡単にしたところ02は匂いを嗅いで追いかけ、匂いがなければ止まる。03で巡回を追加。どちらの段階も、犬は食べず、接触してもパンを取らない。配達・もぐもぐ・勝ち負けもない。匂いがなければ巡回する。食事、接触、配達箱の調査のあとは、2手もぐもぐする。
04と05でできるところ04で面Cのモデル(ルール部分)の決まりをすべて作り、05で Undo を追加。元のゲームの面Cから取った、状態・観測・判断の1,572回分の遷移(状態の移り変わり)のテスト用データ(fixture)と比べてある。たどり着けるすべての状態を調べつくした証明ではない。3つの面すべてのルールと Undo が、いつでも使える。
表示とメニュー図形と英字(ASCII)で表示。タイトル画面なしで面Cから始める。R のあとにキーを離すまで待つ仕組み(解放ゲート)、05の X の優先、ふつうの行動キーを押し続けている数の判定は残している。日本語の解説は、画面ではなく文書で行う。日本語の文字、選択・一時停止・メニュー・次の面、予告の矢印、F12での記録、比較版、ソルバー(答えを自動で探すプログラム)がある。教材では P・M・N・1〜3・F12 を省く。
確かめ方と音単体テスト、元のゲームから取ったテスト用データ(fixture)、本物の Pyxel を画面なし(headless)で動かした描画、プログラムで作ったキー入力で確かめた。音は作っていない。人の操作、OS からの本当の入力、Windows や macOS の画面(GUI)、ブラウザ、音の出力は、まだ確かめていない。元のゲームも音はない。元のゲームの映像や、前からあるテストが通ったことと、新しい教材のテストは、別に記録している。

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

  1. 元のゲームの model.py の active・scent・observation で、情報が犬に届くまでの3つの層を読む。
  2. 元のゲームの policy にある nearest の比べ方と、ふつうの比べ方を、同じ Observation で比べる。
  3. 元のゲームの step で、食事→接触→調査の順番と、配達のあとにも新しい匂いが生まれるところを読む。
  4. 元のゲームの app.py と ui_support.py で、開始・一時停止・切り替えのときの ReleaseGate(キーを離すまで待つ仕組み)を読む。
  5. LEVELS のAとB、plans.py、solver.py で、ほかの面や、パンを節約する方法を調べる。
自分のゲームに変えるなら

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

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