戦略・対戦LESSON 45秘密の根と公開の花をめぐる非対称二人対戦原作083

いたずら庭師の作り方

地上の花と、地下の根。見える情報がちがう。
2人で役を交代しながら、小道と根をのばします。つながっているかの判定、役ごとにちがう表示、画面を相手に渡す手順を学びます。

完成版の遊び方1台の PC を2人で交代して使う対戦です。庭師は花に水を送り、モグラは地下で根をのばします。

いたずら庭師の元の完成版。自動操作によるPyxelの実描画画面。
通常モードの庭師表示。原作の入力処理と描画を使った自動確認画像で、地下の根は隠れています。
PLAY / 試験公開

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

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

完成版の主な操作:矢印マス/Tab行動/Z仮選択/Enter確定、交代幕Enter、X取消、H/P説明

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

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

うまく表示されないとき

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

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

同じ状態から、役ごとに見せる情報を取り出す。

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

1. 上下左右のとなりと、調べ終わった場所の集合
2. 「ある」集合と、「ちゃんとつながっている」集合のちがい
3. 役によってちがう2つの行動と、見せてよい情報の取り出し
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / 集合・タプル / 書きかえない State・つながりを探す方法・見せる情報の取り出し方は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:場所移動(端で折返し) / Tab:行動切替 / Z:仮選択 / Enter:確定/幕を開く/確認 / X:仮選択・確認の取消、確定Undoではない / H:04/05の説明 / R:01〜04初期化、05双方の確認 / Space:03〜05の結果後に役を交換して再試合 / Esc:01〜04終了、05終了確認
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_paths.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

入口につながる小道だけを伸ばす

上下左右のとなりと、調べ終わった場所の集合。

5×5の庭に、0〜24の番号を付けます。番号 p の列は p%5、行は p//5 です。入口は10、巣は14、花壇は 1・11・21・8・18、石は12です。上下左右で場所を選び、Z で仮に選び、Enter で確定します。最初は、小道だけを作る1人用の練習です。花の数字1は飾りで、この段階には水やり・水を吸う・勝ち負けはありません。

neighbors は、番号をいったん x,y に分けて、範囲の中の上下左右のとなりだけを返します。ただ p+1 を使うと、4の右が5になり、行のはしをこえてしまいます。connected は入口を出発点にして、frontier(これから調べる場所)から1つずつ取り出し、まだ調べていない小道を found(見つけた場所)に入れます。found があるので、輪になった道でも、同じところを回り続けません。

legal では、置きたい場所が「すでにある小道・花・石・巣ではない」ことと、「入口につながる小道のとなり」であることを確かめます。盤に線があるだけでは足りません。ほかの場所にはなれた道があっても、そこからはのばせない作りです。

State は frozen な dataclass で、paths(小道)はタプルです。元の状態をこわさずに replace で新しい状態を返すので、受け付けなかった手で時間が進んでいないことも比べやすくなります。選ぶ・動かす・取り消すは、画面の状態だけを変えます。tick が増えるのは、正しい小道を確定したときだけです。

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

01_paths.py / 注目する部分
    return (kind == "path" and cell in range(25)
            and cell not in (*s.paths, *FLOWERS, *ROCKS, NEST)
            and any(n in connected(s.paths, ENTRY) for n in neighbors(cell)))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_paths.py
macOS / Linux
.venv/bin/python 01_paths.py
画面で見ること上 → Z → Enter で5に小道、右 → Z → Enter で6に小道を作ります。PATHS は 5・6・10、TICK 2 です。キーは毎回いったん離してから押します。
  • 番号4と5はとなりではない
  • 入口からつながっているかを探す
  • 受け付けない手や仮の選択では、時間は進まない
つまずきやすいところ「どれかの小道のとなりならいい」と判定しないようにしましょう。connected(paths, ENTRY)(入口からつながった小道)のとなりかどうかを調べます。
少し変えてみよう

上の結果のあと、上 → Z → Enter で花壇1に道を作ろうとしてみましょう。BLOCKED / NO COST(できない・消費なし)となり、道も TICK 2 も変わりません。R で道10だけの状態に戻ります。

01_paths.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(312行)
01_paths.py / 全文
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""いたずら庭師: 日本語教材 1/5。LICENSEとLESSON.mdも読んでください。"""
from dataclasses import dataclass, replace

ENTRY = 10
NEST = 14
FLOWERS = (1, 11, 21, 8, 18)
ROCKS = (12,)


def neighbors(cell):
    """盤面の外へ出ず、左右端もつながない四近傍。"""
    x, y = cell % 5, cell // 5
    return tuple(ny * 5 + nx for nx, ny in
                 ((x-1, y), (x+1, y), (x, y-1), (x, y+1))
                 if 0 <= nx < 5 and 0 <= ny < 5)


def connected(cells, start):
    found = {start} if start in cells else set()
    frontier = list(found)
    while frontier:
        for cell in neighbors(frontier.pop()):
            if cell in cells and cell not in found:
                found.add(cell)
                frontier.append(cell)
    return frozenset(found)

@dataclass(frozen=True)
class State:
    paths: tuple = (ENTRY,)
    tick: int = 0


def initial():
    return State()


def legal(s, kind, cell):
    return (kind == "path" and cell in range(25)
            and cell not in (*s.paths, *FLOWERS, *ROCKS, NEST)
            and any(n in connected(s.paths, ENTRY) for n in neighbors(cell)))


def step(s, action):
    kind, cell = action
    if not legal(s, kind, cell):
        return s
    return replace(s, paths=tuple(sorted((*s.paths, cell))), tick=s.tick+1)


def visible(s):
    return dict(paths=s.paths, roots=(), network=(), water=(1,)*5,
                role="gardener", round=0, left=0, winner="", tick=s.tick,
                drained=(), revealed=False)

def forecast(s, kind, cell):
    if not legal(s, kind, cell):
        return "BLOCKED / NO COST"
    # 掘る前に命中判定しない。庭師の予告は非公開の根に依存しない。
    return {"path": "EXTEND FROM ENTRY", "water": "WATER +1 / MAX 2",
            "root": "EXTEND FROM NEST", "dig": "DIG CENTER + 4 SIDES",
            "cut": "LAB: CUT CENTER + SIDES", "drain": "LAB: DRAIN CONNECTED",
            "wait": "SPEND ONE ACTION"}[kind]

class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.swapped = False
        self.reset()

    def reset(self):
        self.s = initial()
        self.cursor = ENTRY
        self.kind = 0
        self.pending = None
        self.notice = ""
        self.help = False
        self.dialog = ""
        self.curtain = False

    @property
    def kinds(self):
        return ("path",)

    def commit(self, action):
        if self.curtain or self.help or self.dialog:
            return "blocked:overlay"
        before = self.s
        after = step(before, action)
        if after == before:
            return "reject:action"
        self.s = after
        self.pending = None
        self.notice = "ACTION OK"

        return "committed"

    def feed(self, down):
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        # 前半教材は明示したCONTROL_KEYS順で一キーを選ぶ。
        key = next(k for k in CONTROL_KEYS if k in fresh)
        self.notice = ""

        if key == "ESCAPE":
            return "quit"

        if key == "R":
            self.reset()
            return "reset"


        if key == "TAB":
            self.kind = (self.kind+1) % len(self.kinds)
            self.pending = None
            return "kind"
        if key in ("LEFT", "RIGHT", "UP", "DOWN"):
            dx = {"LEFT": -1, "RIGHT": 1}.get(key, 0)
            dy = {"UP": -1, "DOWN": 1}.get(key, 0)
            self.cursor = ((self.cursor//5+dy)%5)*5 + (self.cursor%5+dx)%5
            self.pending = None
            return "cursor"
        if key == "X":
            self.pending = None
            return "cancel_selection"
        if key == "Z":
            kind = self.kinds[self.kind]
            action = (kind, -1 if kind in ("wait", "drain") else self.cursor)
            if legal(self.s, *action):
                self.pending = action
                return "selected"
            self.notice = "BLOCKED / NO COST"
            return "reject:selection"
        if key == "RETURN" and self.pending is not None:
            return self.commit(self.pending)
        return "ignored"


def tap(ui, *keys):
    result = ui.feed(set(keys))
    ui.feed(set())
    return result


def scene(ui):
    # 覆いの場面はここで返す。下の盤面や選択・通知を重ね描きしない。
    if ui.dialog:
        return {"screen": ui.dialog}
    if ui.help:
        return {"screen": "help"}
    if ui.curtain:
        return {"screen": "curtain", "role": ui.s.role, "swapped": ui.swapped}
    view = visible(ui.s)
    view.update(screen="board", cursor=ui.cursor, kind=ui.kinds[ui.kind],
                pending=ui.pending is not None, notice=ui.notice,
                forecast=forecast(ui.s, ui.kinds[ui.kind],
                                  -1 if ui.kinds[ui.kind] in ("wait", "drain") else ui.cursor))
    return view

def draw(p, view):
    p.cls(1)
    p.text(12, 10, "MISCHIEF GARDEN / STAGE 1", 10)
    screen = view["screen"]
    if screen != "board":
        p.rectb(12, 40, 360, 172, 10)
        if screen == "curtain":
            lines = ("HANDOFF / BOARD HIDDEN", "NEXT: "+view["role"].upper(),
                     "OTHER PLAYER: LOOK AWAY", "RELEASE KEYS, THEN ENTER",
                     "ROLES SWAPPED" if view["swapped"] else "TWO PEOPLE / ONE SCREEN",
                     "A PLAY CONVENTION, NOT SECURITY")
        elif screen == "help":
            lines = ("PATH FROM ENTRY / ROOT FROM NEST", "GARDENER: PATH, WATER, DIG*",
                     "MOLE: ROOT / TWO ACTIONS EACH", "AFTER MOLE: CONNECTED ROOTS DRAIN",
                     "ROUND 8: 3 BLOOMS -> GARDENER", "* DIG BEGINS IN STAGE 4",
                     "X CANCELS SELECTION, NEVER UNDO", "H CLOSE / BOARD IS HIDDEN")
        else:
            lines = ({"reset1": "GARDENER: AGREE TO RESET?",
                      "reset2": "MOLE: ALSO AGREE TO RESET?",
                      "quit": "QUIT THIS WINDOW?"}[screen],
                     "RELEASE, ENTER: YES / X: CANCEL",
                     "NO BOARD, PREVIEW OR HISTORY HERE")
        for i, line in enumerate(lines):
            p.text(24, 58+i*18, line, 7 if i else 10)
        return
    p.text(12, 26, view["role"].upper()+" / "+("SOLO LAB" if not view["round"] else
           "ROUND "+str(view["round"])+" / LEFT "+str(view["left"])), 7)
    for cell in range(25):
        x, y = 12+(cell%5)*34, 48+(cell//5)*34
        p.rect(x, y, 31, 31, 4)
        if cell in view["paths"]:
            p.rect(x+2, y+2, 27, 27, 13)
        if cell in ROCKS:
            p.rect(x+6, y+7, 19, 17, 5)
        if cell in view["roots"]:
            color = 10 if cell in view["network"] else 6
            p.circ(x+15, y+15, 3, color)
            for other in neighbors(cell):
                if other in view["roots"]:
                    p.line(x+15, y+15, x+15+(other%5-cell%5)*17,
                           y+15+(other//5-cell//5)*17, color)
        p.text(x+2, y+2, str(cell), 7)
        if cell in FLOWERS:
            water = view["water"][FLOWERS.index(cell)]
            p.circ(x+16, y+16, 5 if water==2 else 3, 14 if water else 5)
            p.text(x+20, y+23, str(water), 7)
        if cell == ENTRY:
            p.text(x+6, y+23, "IN", 1)
        if cell == NEST:
            p.text(x+7, y+23, "NEST", 7)
        if cell in view["drained"]:
            p.text(x+23, y+2, "D", 12)
        if cell == view["cursor"] and not view["winner"]:
            p.rectb(x-1, y-1, 33, 33, 7)
    p.text(194, 50, "BLOOMS "+str(view["water"].count(2))+" / 5", 10)
    p.text(194, 66, "ROOTS: "+("VISIBLE" if view["revealed"] else "HIDDEN"), 11)
    p.text(194, 82, "YELLOW: CONNECTED" if view["revealed"] else "PUBLIC WATER ONLY", 6)
    p.text(194, 94, "GRAY: DISCONNECTED" if view["revealed"] else "NO PRIVATE PREVIEW", 6)
    p.text(194, 112, "ACTION: "+view["kind"].upper(), 10)
    p.text(194, 128, "ENTER: COMMIT" if view["pending"] else "Z: SELECT", 7)
    p.text(194, 144, view["forecast"], 7)
    p.text(194, 160, "TICK "+str(view["tick"]), 6)
    p.text(194, 176, view["notice"], 11)
    if view["winner"]:
        p.text(194, 193, "WIN: "+view["winner"].upper(), 10)
        p.text(194, 205, "SPACE: SWAP AND REPLAY", 7)
    p.text(12, 228, "ARROWS CELL / TAB ACTION / Z SELECT / ENTER COMMIT", 7)
    p.text(12, 241, "X CANCEL / R RESET / H HELP* / ESC QUIT", 6)

CONTROL_KEYS = ("ESCAPE", "R", "H", "RETURN", "SPACE", "X", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "Z")
DEMO_ACTIONS = (('path', 5), ('path', 6))


def demo_controller():
    ui = Controller()
    if ui.curtain:
        tap(ui, "RETURN")
    for action in DEMO_ACTIONS:
        if ui.curtain:
            tap(ui, "RETURN")
        ui.kind = ui.kinds.index(action[0])
        ui.cursor = action[1] if action[1] >= 0 else ui.cursor
        tap(ui, "Z")
        tap(ui, "RETURN")

    return ui


def main(headless=False, screenshot=None, demo=False):
    # import時にウィンドウを開かない。モデルのテストにPyxelは不要。
    from pathlib import Path
    import pyxel as p
    output = Path(screenshot).resolve() if screenshot else None
    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
    ui = demo_controller() if demo else Controller()
    p.init(384, 256, title="Mischief Garden 1", fps=30,
           display_scale=2, quit_key=p.KEY_NONE, headless=headless)
    keymap = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    def update():
        if headless and p.frame_count >= 8:
            if output:
                p.screenshot(str(output), scale=2)
            p.quit()
            return
        result = ui.feed({key for key, value in keymap.items() if p.btn(value)})
        if result == "quit":
            p.quit()
    p.run(update, lambda: draw(p, scene(ui)))


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="いたずら庭師: 単体実行できる段階教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 02 / 05

切れて残る根と、巣につながる根を分ける

「ある」集合と、「ちゃんとつながっている」集合のちがい。

ここだけは、モグラ役の1人用の実験です。最初のカーソルは巣14です。Tab で ROOT・CUT・DRAIN を切りかえます。ROOT は巣につながる根から1マスのばす、CUT は選んだまん中と上下左右の根を切る、DRAIN は巣につながった根がある花壇から水を1減らす操作です。ROOT は元のゲームと同じつながりのルールですが、CUT は小道との近さに関係なく使える、実験用の操作です。対戦での庭師の「掘る」は 04 で作ります。

根がある場所 s.roots と、水を吸える場所 connected(s.roots, NEST)(巣からつながった根)は、別の集合です。途中を切っても、先の根は消えずに灰色で残ります。はなれた先の根から、新しい根をのばすことはできません。巣から別の道でつなぎ直せば、残っていた根もまた黄色になり、水を吸えるようになります。

CUT は、まん中と上下左右の集合から巣(NEST)を外してから、根を取り除きます。花・道・水はけずりません。DRAIN は、1回のつながりの探索の結果で全部の花壇を更新し、max(0, ...) で水が0より下にならないようにします。drained には、今回本当に水が減った花壇だけを入れます。水0の花壇を吸おうとしても、D の印は付きません。

この段階は根の形が全部見える実験で、ひみつの対戦ではありません。交代・2行動・8ラウンド・勝ち負けもまだありません。Tab で選ぶ行動を変えたら、前の仮の選択を必ず消します。根を仮に選んだまま、表示だけが CUT になる食いちがいを防ぐためです。

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

02_roots.py / 注目する部分
    network = connected(s.roots, NEST)
    drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and s.water[i] > 0)
    water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, s.water))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_roots.py
macOS / Linux
.venv/bin/python 02_roots.py
画面で見ることROOT で 13 → 8 → 3 → 2 → 1 の順に確定し、CUT で13を確定します。根の 1・2・3 は灰色で残り、14だけが黄色です。配っているチェックポイントも、この状態です。
  • 「ある」と「ちゃんとつながっている」を区別する
  • 切れた先の根は残す
  • 水は0より下にならない
つまずきやすいところ根を切ったときに、巣に届かない根を全部消してしまわないようにしましょう。元のゲームでは、はなれた枝が残り、つなぎ直すことが作戦になります。
少し変えてみよう

上の結果から DRAIN を確定しても、水は全部1のままです。ROOT で9、次に4を足してから DRAIN を確定すると、枝 1・2・3 がつながり直り、花壇1の水だけが0になって、D の印が付きます。

02_roots.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(327行)
02_roots.py / 全文
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""いたずら庭師: 日本語教材 2/5。LICENSEとLESSON.mdも読んでください。"""
from dataclasses import dataclass, replace

ENTRY = 10
NEST = 14
FLOWERS = (1, 11, 21, 8, 18)
ROCKS = (12,)


def neighbors(cell):
    """盤面の外へ出ず、左右端もつながない四近傍。"""
    x, y = cell % 5, cell // 5
    return tuple(ny * 5 + nx for nx, ny in
                 ((x-1, y), (x+1, y), (x, y-1), (x, y+1))
                 if 0 <= nx < 5 and 0 <= ny < 5)


def connected(cells, start):
    found = {start} if start in cells else set()
    frontier = list(found)
    while frontier:
        for cell in neighbors(frontier.pop()):
            if cell in cells and cell not in found:
                found.add(cell)
                frontier.append(cell)
    return frozenset(found)

@dataclass(frozen=True)
class State:
    roots: tuple = (NEST,)
    water: tuple = (1, 1, 1, 1, 1)
    drained: tuple = ()
    tick: int = 0


def initial():
    return State()


def legal(s, kind, cell):
    if kind == "drain":
        return True
    if cell not in range(25):
        return False
    if kind == "cut":
        return cell not in (*ROCKS, NEST)
    return (kind == "root" and cell not in (*s.roots, *ROCKS)
            and any(n in connected(s.roots, NEST) for n in neighbors(cell)))


def step(s, action):
    kind, cell = action
    if not legal(s, kind, cell):
        return s
    if kind == "root":
        return replace(s, roots=tuple(sorted((*s.roots, cell))), tick=s.tick+1)
    if kind == "cut":
        removed = set((cell, *neighbors(cell))) - {NEST}
        return replace(s, roots=tuple(p for p in s.roots if p not in removed), tick=s.tick+1)
    network = connected(s.roots, NEST)
    drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and s.water[i] > 0)
    water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, s.water))
    return replace(s, water=water, drained=drained, tick=s.tick+1)


def visible(s):
    return dict(paths=(ENTRY,), roots=s.roots, network=connected(s.roots, NEST),
                water=s.water, role="mole", round=0, left=0, winner="",
                tick=s.tick, drained=s.drained, revealed=True)

def forecast(s, kind, cell):
    if not legal(s, kind, cell):
        return "BLOCKED / NO COST"
    # 掘る前に命中判定しない。庭師の予告は非公開の根に依存しない。
    return {"path": "EXTEND FROM ENTRY", "water": "WATER +1 / MAX 2",
            "root": "EXTEND FROM NEST", "dig": "DIG CENTER + 4 SIDES",
            "cut": "LAB: CUT CENTER + SIDES", "drain": "LAB: DRAIN CONNECTED",
            "wait": "SPEND ONE ACTION"}[kind]

class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.swapped = False
        self.reset()

    def reset(self):
        self.s = initial()
        self.cursor = NEST
        self.kind = 0
        self.pending = None
        self.notice = ""
        self.help = False
        self.dialog = ""
        self.curtain = False

    @property
    def kinds(self):
        return ("root", "cut", "drain")

    def commit(self, action):
        if self.curtain or self.help or self.dialog:
            return "blocked:overlay"
        before = self.s
        after = step(before, action)
        if after == before:
            return "reject:action"
        self.s = after
        self.pending = None
        self.notice = "ACTION OK"

        return "committed"

    def feed(self, down):
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        # 前半教材は明示したCONTROL_KEYS順で一キーを選ぶ。
        key = next(k for k in CONTROL_KEYS if k in fresh)
        self.notice = ""

        if key == "ESCAPE":
            return "quit"

        if key == "R":
            self.reset()
            return "reset"


        if key == "TAB":
            self.kind = (self.kind+1) % len(self.kinds)
            self.pending = None
            return "kind"
        if key in ("LEFT", "RIGHT", "UP", "DOWN"):
            dx = {"LEFT": -1, "RIGHT": 1}.get(key, 0)
            dy = {"UP": -1, "DOWN": 1}.get(key, 0)
            self.cursor = ((self.cursor//5+dy)%5)*5 + (self.cursor%5+dx)%5
            self.pending = None
            return "cursor"
        if key == "X":
            self.pending = None
            return "cancel_selection"
        if key == "Z":
            kind = self.kinds[self.kind]
            action = (kind, -1 if kind in ("wait", "drain") else self.cursor)
            if legal(self.s, *action):
                self.pending = action
                return "selected"
            self.notice = "BLOCKED / NO COST"
            return "reject:selection"
        if key == "RETURN" and self.pending is not None:
            return self.commit(self.pending)
        return "ignored"


def tap(ui, *keys):
    result = ui.feed(set(keys))
    ui.feed(set())
    return result


def scene(ui):
    # 覆いの場面はここで返す。下の盤面や選択・通知を重ね描きしない。
    if ui.dialog:
        return {"screen": ui.dialog}
    if ui.help:
        return {"screen": "help"}
    if ui.curtain:
        return {"screen": "curtain", "role": ui.s.role, "swapped": ui.swapped}
    view = visible(ui.s)
    view.update(screen="board", cursor=ui.cursor, kind=ui.kinds[ui.kind],
                pending=ui.pending is not None, notice=ui.notice,
                forecast=forecast(ui.s, ui.kinds[ui.kind],
                                  -1 if ui.kinds[ui.kind] in ("wait", "drain") else ui.cursor))
    return view

def draw(p, view):
    p.cls(1)
    p.text(12, 10, "MISCHIEF GARDEN / STAGE 2", 10)
    screen = view["screen"]
    if screen != "board":
        p.rectb(12, 40, 360, 172, 10)
        if screen == "curtain":
            lines = ("HANDOFF / BOARD HIDDEN", "NEXT: "+view["role"].upper(),
                     "OTHER PLAYER: LOOK AWAY", "RELEASE KEYS, THEN ENTER",
                     "ROLES SWAPPED" if view["swapped"] else "TWO PEOPLE / ONE SCREEN",
                     "A PLAY CONVENTION, NOT SECURITY")
        elif screen == "help":
            lines = ("PATH FROM ENTRY / ROOT FROM NEST", "GARDENER: PATH, WATER, DIG*",
                     "MOLE: ROOT / TWO ACTIONS EACH", "AFTER MOLE: CONNECTED ROOTS DRAIN",
                     "ROUND 8: 3 BLOOMS -> GARDENER", "* DIG BEGINS IN STAGE 4",
                     "X CANCELS SELECTION, NEVER UNDO", "H CLOSE / BOARD IS HIDDEN")
        else:
            lines = ({"reset1": "GARDENER: AGREE TO RESET?",
                      "reset2": "MOLE: ALSO AGREE TO RESET?",
                      "quit": "QUIT THIS WINDOW?"}[screen],
                     "RELEASE, ENTER: YES / X: CANCEL",
                     "NO BOARD, PREVIEW OR HISTORY HERE")
        for i, line in enumerate(lines):
            p.text(24, 58+i*18, line, 7 if i else 10)
        return
    p.text(12, 26, view["role"].upper()+" / "+("SOLO LAB" if not view["round"] else
           "ROUND "+str(view["round"])+" / LEFT "+str(view["left"])), 7)
    for cell in range(25):
        x, y = 12+(cell%5)*34, 48+(cell//5)*34
        p.rect(x, y, 31, 31, 4)
        if cell in view["paths"]:
            p.rect(x+2, y+2, 27, 27, 13)
        if cell in ROCKS:
            p.rect(x+6, y+7, 19, 17, 5)
        if cell in view["roots"]:
            color = 10 if cell in view["network"] else 6
            p.circ(x+15, y+15, 3, color)
            for other in neighbors(cell):
                if other in view["roots"]:
                    p.line(x+15, y+15, x+15+(other%5-cell%5)*17,
                           y+15+(other//5-cell//5)*17, color)
        p.text(x+2, y+2, str(cell), 7)
        if cell in FLOWERS:
            water = view["water"][FLOWERS.index(cell)]
            p.circ(x+16, y+16, 5 if water==2 else 3, 14 if water else 5)
            p.text(x+20, y+23, str(water), 7)
        if cell == ENTRY:
            p.text(x+6, y+23, "IN", 1)
        if cell == NEST:
            p.text(x+7, y+23, "NEST", 7)
        if cell in view["drained"]:
            p.text(x+23, y+2, "D", 12)
        if cell == view["cursor"] and not view["winner"]:
            p.rectb(x-1, y-1, 33, 33, 7)
    p.text(194, 50, "BLOOMS "+str(view["water"].count(2))+" / 5", 10)
    p.text(194, 66, "ROOTS: "+("VISIBLE" if view["revealed"] else "HIDDEN"), 11)
    p.text(194, 82, "YELLOW: CONNECTED" if view["revealed"] else "PUBLIC WATER ONLY", 6)
    p.text(194, 94, "GRAY: DISCONNECTED" if view["revealed"] else "NO PRIVATE PREVIEW", 6)
    p.text(194, 112, "ACTION: "+view["kind"].upper(), 10)
    p.text(194, 128, "ENTER: COMMIT" if view["pending"] else "Z: SELECT", 7)
    p.text(194, 144, view["forecast"], 7)
    p.text(194, 160, "TICK "+str(view["tick"]), 6)
    p.text(194, 176, view["notice"], 11)
    if view["winner"]:
        p.text(194, 193, "WIN: "+view["winner"].upper(), 10)
        p.text(194, 205, "SPACE: SWAP AND REPLAY", 7)
    p.text(12, 228, "ARROWS CELL / TAB ACTION / Z SELECT / ENTER COMMIT", 7)
    p.text(12, 241, "X CANCEL / R RESET / H HELP* / ESC QUIT", 6)

CONTROL_KEYS = ("ESCAPE", "R", "H", "RETURN", "SPACE", "X", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "Z")
DEMO_ACTIONS = (('root', 13), ('root', 8), ('root', 3), ('root', 2), ('root', 1), ('cut', 13))


def demo_controller():
    ui = Controller()
    if ui.curtain:
        tap(ui, "RETURN")
    for action in DEMO_ACTIONS:
        if ui.curtain:
            tap(ui, "RETURN")
        ui.kind = ui.kinds.index(action[0])
        ui.cursor = action[1] if action[1] >= 0 else ui.cursor
        tap(ui, "Z")
        tap(ui, "RETURN")

    return ui


def main(headless=False, screenshot=None, demo=False):
    # import時にウィンドウを開かない。モデルのテストにPyxelは不要。
    from pathlib import Path
    import pyxel as p
    output = Path(screenshot).resolve() if screenshot else None
    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
    ui = demo_controller() if demo else Controller()
    p.init(384, 256, title="Mischief Garden 2", fps=30,
           display_scale=2, quit_key=p.KEY_NONE, headless=headless)
    keymap = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    def update():
        if headless and p.frame_count >= 8:
            if output:
                p.screenshot(str(output), scale=2)
            p.quit()
            return
        result = ui.feed({key for key, value in keymap.items() if p.btn(value)})
        if result == "quit":
            p.quit()
    p.run(update, lambda: draw(p, scene(ui)))


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="いたずら庭師: 単体実行できる段階教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 03 / 05

役の違う二人を、交代幕でつなぐ

役によってちがう2つの行動と、見せてよい情報の取り出し。

ここからは、同じ画面を交代で使う2人用です。最初の「幕(目かくしの画面)」では、庭師だけが Enter を押します。庭師は PATH・WATER・WAIT、モグラは ROOT・WAIT を選べます。それぞれの役が2回行動すると、交代の幕が出ます。次の役の人以外は画面を見ない約束をして、キーを全部離してから Enter で幕を開けます。幕を描くだけでは足りず、幕が出ている間は、盤の操作も受け付けません。

庭師の小道は入口10から、モグラの根は巣14から、つながりを探します。水やりは、入口につながった小道の上か、そのとなりの花にだけできます。水が2になると花が咲きます。正しい手のときだけ left(残りの行動)を減らし、庭師の2手目のあとはモグラに交代します。モグラの2手目のあとは、まず水を吸う処理をいっせいに計算してから、次のラウンドに進みます。

8ラウンド目の吸う処理が終わった時点で、水2の花が3つ以上なら庭師の勝ち、2つ以下ならモグラの勝ちです。途中で3つ咲いても、そこで勝ちにはなりません。終わったら根を公開し、状態を固定します。Space は、2人が役を交代して同じ庭で再戦するための操作です。アカウントや本人の確認はありません。

visible は、庭師に渡す情報をはっきり作るもので、根の形・つながった集合・相手の最後の操作は入れません。draw はこの辞書しか受け取らないので、点数の欄や予告から、うっかり根を読んでしまうことを減らせます。水を吸われた花は、見えている手がかりです。役を交代するときは、カーソル・行動・仮の選択・お知らせを最初に戻し、前の人が最後に見ていた場所を残しません。

まだ DIG(掘る)はなく、元のゲームの状況 A の一部のルールです。R はすぐに最初に戻して、庭師用の幕を出します。2人が同意してからリセットする仕組みは 05 で加えます。画面の「ひみつ」は遊びの約束で、セキュリティやネットワークのプライバシーではありません。

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

03_turns.py / 注目する部分
    reveal = s.role == "mole" or bool(s.winner)
    return dict(paths=s.paths, water=s.water, round=s.round, role=s.role,
                left=s.left, drained=s.drained, winner=s.winner, tick=s.tick,
                roots=s.roots if reveal else (),
                network=root_network(s) if reveal else (), revealed=reveal)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_turns.py
macOS / Linux
.venv/bin/python 03_turns.py
画面で見ること庭師が PATH 5、PATH 6。幕を渡して、モグラが ROOT 13、ROOT 8。次の庭師が幕を開くと ROUND 2 / LEFT 2 で、花壇8の水が0になって D の印が見えますが、根の 13・8 は見えません。
  • 役ごとに、できる手を分ける
  • 交代するときに、ひみつの選択を消す
  • 最後の吸う処理のあとで勝ち負けを決める
つまずきやすいところ水やりの直後に、水を吸わせないようにしましょう。モグラの2回の行動が終わってから、1回だけ吸います。根の数の合計を、庭師の点数の欄に出すのも情報もれです。
少し変えてみよう

庭師の番で、正しい場所を Z で仮に選び、X で取り消してみましょう。TICK と残りの行動は変わりません。Z → Enter で確定したあとに X を押しても、その1手は戻りません。

03_turns.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(385行)
03_turns.py / 全文
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""いたずら庭師: 日本語教材 3/5。LICENSEとLESSON.mdも読んでください。"""
from dataclasses import dataclass, replace

ENTRY = 10
NEST = 14
FLOWERS = (1, 11, 21, 8, 18)
ROCKS = (12,)


def neighbors(cell):
    """盤面の外へ出ず、左右端もつながない四近傍。"""
    x, y = cell % 5, cell // 5
    return tuple(ny * 5 + nx for nx, ny in
                 ((x-1, y), (x+1, y), (x, y-1), (x, y+1))
                 if 0 <= nx < 5 and 0 <= ny < 5)


def connected(cells, start):
    found = {start} if start in cells else set()
    frontier = list(found)
    while frontier:
        for cell in neighbors(frontier.pop()):
            if cell in cells and cell not in found:
                found.add(cell)
                frontier.append(cell)
    return frozenset(found)

@dataclass(frozen=True)
class State:
    paths: tuple = (ENTRY,)
    roots: tuple = (NEST,)
    water: tuple = (1, 1, 1, 1, 1)
    round: int = 1
    role: str = "gardener"
    left: int = 2
    drained: tuple = ()
    winner: str = ""
    tick: int = 0
    last: str = "READY"


def initial():
    return State()


def path_network(s):
    return connected(s.paths, ENTRY)


def root_network(s):
    return connected(s.roots, NEST)


def legal(s, kind, cell=-1):
    if s.winner:
        return False
    if kind == "wait":
        return True
    if cell not in range(25):
        return False
    if s.role == "gardener":
        network = path_network(s)
        near = cell in network or any(n in network for n in neighbors(cell))
        if kind == "path":
            return (cell not in (*s.paths, *FLOWERS, *ROCKS, NEST)
                    and any(n in network for n in neighbors(cell)))
        if kind == "water":
            return cell in FLOWERS and near and s.water[FLOWERS.index(cell)] < 2

    elif s.role == "mole" and kind == "root":
        return (cell not in (*s.roots, *ROCKS)
                and any(n in root_network(s) for n in neighbors(cell)))
    return False


def step(s, action):
    kind, cell = action
    if not legal(s, kind, cell):
        return s
    paths, roots, water = set(s.paths), set(s.roots), list(s.water)
    if kind == "path":
        paths.add(cell)
    elif kind == "root":
        roots.add(cell)
    elif kind == "water":
        water[FLOWERS.index(cell)] += 1

    last = {"path": "PATH ADDED", "root": "UNDERGROUND WORK DONE",
            "water": "WATER ADDED", "wait": "WAITED"}.get(kind, "DIG DONE")

    n = replace(s, paths=tuple(sorted(paths)), roots=tuple(sorted(roots)),
                water=tuple(water), left=s.left-1, tick=s.tick+1, last=last)
    if n.left:
        return n
    if n.role == "gardener":
        return replace(n, role="mole", left=2, last="HANDOFF")
    # 吸水はモグラの二手目の後だけ。全花壇を同じ連結集合で計算する。
    network = root_network(n)
    drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and water[i] > 0)
    water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, water))
    winner = ("gardener" if water.count(2) >= 3 else "mole") if n.round == 8 else ""
    return replace(n, water=water, drained=drained, role="gardener", left=2,
                   round=n.round if winner else n.round+1, winner=winner, last="DRAIN FINISHED")


def visible(s):
    # 庭師用辞書には根、根数、私的な最終操作を一切入れない。
    reveal = s.role == "mole" or bool(s.winner)
    return dict(paths=s.paths, water=s.water, round=s.round, role=s.role,
                left=s.left, drained=s.drained, winner=s.winner, tick=s.tick,
                roots=s.roots if reveal else (),
                network=root_network(s) if reveal else (), revealed=reveal)

def forecast(s, kind, cell):
    if not legal(s, kind, cell):
        return "BLOCKED / NO COST"
    # 掘る前に命中判定しない。庭師の予告は非公開の根に依存しない。
    return {"path": "EXTEND FROM ENTRY", "water": "WATER +1 / MAX 2",
            "root": "EXTEND FROM NEST", "dig": "DIG CENTER + 4 SIDES",
            "cut": "LAB: CUT CENTER + SIDES", "drain": "LAB: DRAIN CONNECTED",
            "wait": "SPEND ONE ACTION"}[kind]

class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.swapped = False
        self.reset()

    def reset(self):
        self.s = initial()
        self.cursor = ENTRY
        self.kind = 0
        self.pending = None
        self.notice = ""
        self.help = False
        self.dialog = ""
        self.curtain = True

    @property
    def kinds(self):
        return ("path", "water", "wait") if self.s.role == "gardener" else ("root", "wait")

    def commit(self, action):
        if self.curtain or self.help or self.dialog:
            return "blocked:overlay"
        before = self.s
        after = step(before, action)
        if after == before:
            return "reject:action"
        self.s = after
        self.pending = None
        self.notice = after.last
        if before.role != after.role and not after.winner:
            self.curtain = True
            self.kind = 0
            self.cursor = ENTRY if after.role == "gardener" else NEST
            self.notice = ""
            self.pending = None
        return "committed"

    def feed(self, down):
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        # 前半教材は明示したCONTROL_KEYS順で一キーを選ぶ。
        key = next(k for k in CONTROL_KEYS if k in fresh)
        self.notice = ""

        if key == "ESCAPE":
            return "quit"

        if key == "R":
            self.reset()
            return "reset"
        if self.curtain:
            if key == "RETURN":
                self.curtain = False
                return "ready"
            return "ignored:curtain"
        if self.s.winner:
            if key == "SPACE":
                self.swapped = not self.swapped
                self.reset()
                return "swap_roles"
            return "ignored:result"
        if key == "TAB":
            self.kind = (self.kind+1) % len(self.kinds)
            self.pending = None
            return "kind"
        if key in ("LEFT", "RIGHT", "UP", "DOWN"):
            dx = {"LEFT": -1, "RIGHT": 1}.get(key, 0)
            dy = {"UP": -1, "DOWN": 1}.get(key, 0)
            self.cursor = ((self.cursor//5+dy)%5)*5 + (self.cursor%5+dx)%5
            self.pending = None
            return "cursor"
        if key == "X":
            self.pending = None
            return "cancel_selection"
        if key == "Z":
            kind = self.kinds[self.kind]
            action = (kind, -1 if kind in ("wait", "drain") else self.cursor)
            if legal(self.s, *action):
                self.pending = action
                return "selected"
            self.notice = "BLOCKED / NO COST"
            return "reject:selection"
        if key == "RETURN" and self.pending is not None:
            return self.commit(self.pending)
        return "ignored"


def tap(ui, *keys):
    result = ui.feed(set(keys))
    ui.feed(set())
    return result


def scene(ui):
    # 覆いの場面はここで返す。下の盤面や選択・通知を重ね描きしない。
    if ui.dialog:
        return {"screen": ui.dialog}
    if ui.help:
        return {"screen": "help"}
    if ui.curtain:
        return {"screen": "curtain", "role": ui.s.role, "swapped": ui.swapped}
    view = visible(ui.s)
    view.update(screen="board", cursor=ui.cursor, kind=ui.kinds[ui.kind],
                pending=ui.pending is not None, notice=ui.notice,
                forecast=forecast(ui.s, ui.kinds[ui.kind],
                                  -1 if ui.kinds[ui.kind] in ("wait", "drain") else ui.cursor))
    return view

def draw(p, view):
    p.cls(1)
    p.text(12, 10, "MISCHIEF GARDEN / STAGE 3", 10)
    screen = view["screen"]
    if screen != "board":
        p.rectb(12, 40, 360, 172, 10)
        if screen == "curtain":
            lines = ("HANDOFF / BOARD HIDDEN", "NEXT: "+view["role"].upper(),
                     "OTHER PLAYER: LOOK AWAY", "RELEASE KEYS, THEN ENTER",
                     "ROLES SWAPPED" if view["swapped"] else "TWO PEOPLE / ONE SCREEN",
                     "A PLAY CONVENTION, NOT SECURITY")
        elif screen == "help":
            lines = ("PATH FROM ENTRY / ROOT FROM NEST", "GARDENER: PATH, WATER, DIG*",
                     "MOLE: ROOT / TWO ACTIONS EACH", "AFTER MOLE: CONNECTED ROOTS DRAIN",
                     "ROUND 8: 3 BLOOMS -> GARDENER", "* DIG BEGINS IN STAGE 4",
                     "X CANCELS SELECTION, NEVER UNDO", "H CLOSE / BOARD IS HIDDEN")
        else:
            lines = ({"reset1": "GARDENER: AGREE TO RESET?",
                      "reset2": "MOLE: ALSO AGREE TO RESET?",
                      "quit": "QUIT THIS WINDOW?"}[screen],
                     "RELEASE, ENTER: YES / X: CANCEL",
                     "NO BOARD, PREVIEW OR HISTORY HERE")
        for i, line in enumerate(lines):
            p.text(24, 58+i*18, line, 7 if i else 10)
        return
    p.text(12, 26, view["role"].upper()+" / "+("SOLO LAB" if not view["round"] else
           "ROUND "+str(view["round"])+" / LEFT "+str(view["left"])), 7)
    for cell in range(25):
        x, y = 12+(cell%5)*34, 48+(cell//5)*34
        p.rect(x, y, 31, 31, 4)
        if cell in view["paths"]:
            p.rect(x+2, y+2, 27, 27, 13)
        if cell in ROCKS:
            p.rect(x+6, y+7, 19, 17, 5)
        if cell in view["roots"]:
            color = 10 if cell in view["network"] else 6
            p.circ(x+15, y+15, 3, color)
            for other in neighbors(cell):
                if other in view["roots"]:
                    p.line(x+15, y+15, x+15+(other%5-cell%5)*17,
                           y+15+(other//5-cell//5)*17, color)
        p.text(x+2, y+2, str(cell), 7)
        if cell in FLOWERS:
            water = view["water"][FLOWERS.index(cell)]
            p.circ(x+16, y+16, 5 if water==2 else 3, 14 if water else 5)
            p.text(x+20, y+23, str(water), 7)
        if cell == ENTRY:
            p.text(x+6, y+23, "IN", 1)
        if cell == NEST:
            p.text(x+7, y+23, "NEST", 7)
        if cell in view["drained"]:
            p.text(x+23, y+2, "D", 12)
        if cell == view["cursor"] and not view["winner"]:
            p.rectb(x-1, y-1, 33, 33, 7)
    p.text(194, 50, "BLOOMS "+str(view["water"].count(2))+" / 5", 10)
    p.text(194, 66, "ROOTS: "+("VISIBLE" if view["revealed"] else "HIDDEN"), 11)
    p.text(194, 82, "YELLOW: CONNECTED" if view["revealed"] else "PUBLIC WATER ONLY", 6)
    p.text(194, 94, "GRAY: DISCONNECTED" if view["revealed"] else "NO PRIVATE PREVIEW", 6)
    p.text(194, 112, "ACTION: "+view["kind"].upper(), 10)
    p.text(194, 128, "ENTER: COMMIT" if view["pending"] else "Z: SELECT", 7)
    p.text(194, 144, view["forecast"], 7)
    p.text(194, 160, "TICK "+str(view["tick"]), 6)
    p.text(194, 176, view["notice"], 11)
    if view["winner"]:
        p.text(194, 193, "WIN: "+view["winner"].upper(), 10)
        p.text(194, 205, "SPACE: SWAP AND REPLAY", 7)
    p.text(12, 228, "ARROWS CELL / TAB ACTION / Z SELECT / ENTER COMMIT", 7)
    p.text(12, 241, "X CANCEL / R RESET / H HELP* / ESC QUIT", 6)

CONTROL_KEYS = ("ESCAPE", "R", "H", "RETURN", "SPACE", "X", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "Z")
DEMO_ACTIONS = (('path', 5), ('path', 6), ('root', 13), ('root', 8))


def demo_controller():
    ui = Controller()
    if ui.curtain:
        tap(ui, "RETURN")
    for action in DEMO_ACTIONS:
        if ui.curtain:
            tap(ui, "RETURN")
        ui.kind = ui.kinds.index(action[0])
        ui.cursor = action[1] if action[1] >= 0 else ui.cursor
        tap(ui, "Z")
        tap(ui, "RETURN")
    if ui.curtain:
        tap(ui, "RETURN")
    return ui


def main(headless=False, screenshot=None, demo=False):
    # import時にウィンドウを開かない。モデルのテストにPyxelは不要。
    from pathlib import Path
    import pyxel as p
    output = Path(screenshot).resolve() if screenshot else None
    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
    ui = demo_controller() if demo else Controller()
    p.init(384, 256, title="Mischief Garden 3", fps=30,
           display_scale=2, quit_key=p.KEY_NONE, headless=headless)
    keymap = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    def update():
        if headless and p.frame_count >= 8:
            if output:
                p.screenshot(str(output), scale=2)
            p.quit()
            return
        result = ui.feed({key for key, value in keymap.items() if p.btn(value)})
        if result == "quit":
            p.quit()
    p.run(update, lambda: draw(p, scene(ui)))


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="いたずら庭師: 単体実行できる段階教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 04 / 05

十字掘りを、秘密を漏らさず確定する

中身のモデルを完成させ、安全に予告する。

庭師の DIG(掘る)を加えて、元のゲームの状況 A「五色の花壇」のふつうのルールを完成させます。掘るまん中は、小道の上かそのとなりで、石12と巣14をまん中にはできません。まん中と上下左右の根を切りますが、巣そのものは残します。まん中以外の場所が小道から遠くても、十字の範囲なら切れます。花・小道・水の量は、掘る前のままです。

掘れるかどうかは、庭師に見えている小道・石・巣だけで決めます。根がある場所でしか DIG できないようにすると、受け付けられないという表示だけで、地下をさぐれてしまいます。空振りも、正しい1回の行動です。forecast も「まん中と4方向を掘る」という決まった説明だけを返し、掘る前に、何本切れるかや、残る根は見せません。

確定したあとでだけ、元の根の集合と、更新したあとの差から、ROOT CUT(根を切った)か NO ROOT(根はなかった)を知らせます。この結果は、庭師が行動を使って手に入れた手がかりです。次の役に渡すときは、お知らせを消します。ふつうに遊ぶときは、ひみつの操作の記録・状態の書き出し・デバッグの記録を保存せず、予告や点数にも出しません。

H で説明を開くと、盤を描く前に return します。先に盤を描いてから小さな説明の窓を重ねるやり方では、まわりに根が見えてしまうおそれがあるからです。説明中は、操作もラウンドの進行も止めます。根だけがちがう2つの状態に同じ庭師の操作をして、描く命令が同じになることをテストし、情報の流れを確かめています。

元のゲームの状況 A の、できる手・できない手・水を吸う処理・勝ち負けを再現していますが、画面は簡単な文字(ASCII)です。元のゲームの状況 B・C、表紙、全部見せるモードの切りかえ、F12、常に記録する機能、日本語の文字は省いています。元のゲームの遊び方全体が同じ、という意味ではありません。

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

04_digging.py / 注目する部分
    elif kind == "dig":
        roots.difference_update(q for q in (cell, *neighbors(cell)) if q != NEST)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_digging.py
macOS / Linux
.venv/bin/python 04_digging.py
画面で見ること庭師が PATH 5・PATH 6、モグラが ROOT 13・ROOT 8。そのあと庭師が DIG 7 を確定すると、ROOT CUT、LEFT 1 になり、地下では8だけが切れて、13と14が残ります。庭師の盤には、その根は表示しません。
  • 空振りも1回の行動
  • 巣・花・道はこわさない
  • 掘る予告は、根があるかどうかで変わらない
つまずきやすいところ仮の選択や説明の欄で、「根が1本切れます」と先に計算して表示しないようにしましょう。根があったかどうかは、確定したあとにだけ知らせます。
少し変えてみよう

小道・水・手番は同じで、根だけがちがう2つの状態を作り、DIG ができるかと forecast を比べてみましょう。どのまん中でも同じで、確定したあとの結果だけが変わることを、test_hidden_information_noninterference で確かめます。

04_digging.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(391行)
04_digging.py / 全文
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""いたずら庭師: 日本語教材 4/5。LICENSEとLESSON.mdも読んでください。"""
from dataclasses import dataclass, replace

ENTRY = 10
NEST = 14
FLOWERS = (1, 11, 21, 8, 18)
ROCKS = (12,)


def neighbors(cell):
    """盤面の外へ出ず、左右端もつながない四近傍。"""
    x, y = cell % 5, cell // 5
    return tuple(ny * 5 + nx for nx, ny in
                 ((x-1, y), (x+1, y), (x, y-1), (x, y+1))
                 if 0 <= nx < 5 and 0 <= ny < 5)


def connected(cells, start):
    found = {start} if start in cells else set()
    frontier = list(found)
    while frontier:
        for cell in neighbors(frontier.pop()):
            if cell in cells and cell not in found:
                found.add(cell)
                frontier.append(cell)
    return frozenset(found)

@dataclass(frozen=True)
class State:
    paths: tuple = (ENTRY,)
    roots: tuple = (NEST,)
    water: tuple = (1, 1, 1, 1, 1)
    round: int = 1
    role: str = "gardener"
    left: int = 2
    drained: tuple = ()
    winner: str = ""
    tick: int = 0
    last: str = "READY"


def initial():
    return State()


def path_network(s):
    return connected(s.paths, ENTRY)


def root_network(s):
    return connected(s.roots, NEST)


def legal(s, kind, cell=-1):
    if s.winner:
        return False
    if kind == "wait":
        return True
    if cell not in range(25):
        return False
    if s.role == "gardener":
        network = path_network(s)
        near = cell in network or any(n in network for n in neighbors(cell))
        if kind == "path":
            return (cell not in (*s.paths, *FLOWERS, *ROCKS, NEST)
                    and any(n in network for n in neighbors(cell)))
        if kind == "water":
            return cell in FLOWERS and near and s.water[FLOWERS.index(cell)] < 2
        if kind == "dig":
            return near and cell not in (*ROCKS, NEST)
    elif s.role == "mole" and kind == "root":
        return (cell not in (*s.roots, *ROCKS)
                and any(n in root_network(s) for n in neighbors(cell)))
    return False


def step(s, action):
    kind, cell = action
    if not legal(s, kind, cell):
        return s
    paths, roots, water = set(s.paths), set(s.roots), list(s.water)
    if kind == "path":
        paths.add(cell)
    elif kind == "root":
        roots.add(cell)
    elif kind == "water":
        water[FLOWERS.index(cell)] += 1
    elif kind == "dig":
        roots.difference_update(q for q in (cell, *neighbors(cell)) if q != NEST)
    last = {"path": "PATH ADDED", "root": "UNDERGROUND WORK DONE",
            "water": "WATER ADDED", "wait": "WAITED"}.get(kind, "DIG DONE")
    if kind == "dig":
        last = "ROOT CUT" if set(s.roots)-roots else "NO ROOT"
    n = replace(s, paths=tuple(sorted(paths)), roots=tuple(sorted(roots)),
                water=tuple(water), left=s.left-1, tick=s.tick+1, last=last)
    if n.left:
        return n
    if n.role == "gardener":
        return replace(n, role="mole", left=2, last="HANDOFF")
    # 吸水はモグラの二手目の後だけ。全花壇を同じ連結集合で計算する。
    network = root_network(n)
    drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and water[i] > 0)
    water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, water))
    winner = ("gardener" if water.count(2) >= 3 else "mole") if n.round == 8 else ""
    return replace(n, water=water, drained=drained, role="gardener", left=2,
                   round=n.round if winner else n.round+1, winner=winner, last="DRAIN FINISHED")


def visible(s):
    # 庭師用辞書には根、根数、私的な最終操作を一切入れない。
    reveal = s.role == "mole" or bool(s.winner)
    return dict(paths=s.paths, water=s.water, round=s.round, role=s.role,
                left=s.left, drained=s.drained, winner=s.winner, tick=s.tick,
                roots=s.roots if reveal else (),
                network=root_network(s) if reveal else (), revealed=reveal)

def forecast(s, kind, cell):
    if not legal(s, kind, cell):
        return "BLOCKED / NO COST"
    # 掘る前に命中判定しない。庭師の予告は非公開の根に依存しない。
    return {"path": "EXTEND FROM ENTRY", "water": "WATER +1 / MAX 2",
            "root": "EXTEND FROM NEST", "dig": "DIG CENTER + 4 SIDES",
            "cut": "LAB: CUT CENTER + SIDES", "drain": "LAB: DRAIN CONNECTED",
            "wait": "SPEND ONE ACTION"}[kind]

class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.swapped = False
        self.reset()

    def reset(self):
        self.s = initial()
        self.cursor = ENTRY
        self.kind = 0
        self.pending = None
        self.notice = ""
        self.help = False
        self.dialog = ""
        self.curtain = True

    @property
    def kinds(self):
        return ("path", "water", "dig", "wait") if self.s.role == "gardener" else ("root", "wait")

    def commit(self, action):
        if self.curtain or self.help or self.dialog:
            return "blocked:overlay"
        before = self.s
        after = step(before, action)
        if after == before:
            return "reject:action"
        self.s = after
        self.pending = None
        self.notice = after.last
        if before.role != after.role and not after.winner:
            self.curtain = True
            self.kind = 0
            self.cursor = ENTRY if after.role == "gardener" else NEST
            self.notice = ""
            self.pending = None
        return "committed"

    def feed(self, down):
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        # 前半教材は明示したCONTROL_KEYS順で一キーを選ぶ。
        key = next(k for k in CONTROL_KEYS if k in fresh)
        self.notice = ""

        if key == "ESCAPE":
            return "quit"
        if key == "H":
            self.help = not self.help
            return "help"
        if self.help:
            return "ignored:help"
        if key == "R":
            self.reset()
            return "reset"
        if self.curtain:
            if key == "RETURN":
                self.curtain = False
                return "ready"
            return "ignored:curtain"
        if self.s.winner:
            if key == "SPACE":
                self.swapped = not self.swapped
                self.reset()
                return "swap_roles"
            return "ignored:result"
        if key == "TAB":
            self.kind = (self.kind+1) % len(self.kinds)
            self.pending = None
            return "kind"
        if key in ("LEFT", "RIGHT", "UP", "DOWN"):
            dx = {"LEFT": -1, "RIGHT": 1}.get(key, 0)
            dy = {"UP": -1, "DOWN": 1}.get(key, 0)
            self.cursor = ((self.cursor//5+dy)%5)*5 + (self.cursor%5+dx)%5
            self.pending = None
            return "cursor"
        if key == "X":
            self.pending = None
            return "cancel_selection"
        if key == "Z":
            kind = self.kinds[self.kind]
            action = (kind, -1 if kind in ("wait", "drain") else self.cursor)
            if legal(self.s, *action):
                self.pending = action
                return "selected"
            self.notice = "BLOCKED / NO COST"
            return "reject:selection"
        if key == "RETURN" and self.pending is not None:
            return self.commit(self.pending)
        return "ignored"


def tap(ui, *keys):
    result = ui.feed(set(keys))
    ui.feed(set())
    return result


def scene(ui):
    # 覆いの場面はここで返す。下の盤面や選択・通知を重ね描きしない。
    if ui.dialog:
        return {"screen": ui.dialog}
    if ui.help:
        return {"screen": "help"}
    if ui.curtain:
        return {"screen": "curtain", "role": ui.s.role, "swapped": ui.swapped}
    view = visible(ui.s)
    view.update(screen="board", cursor=ui.cursor, kind=ui.kinds[ui.kind],
                pending=ui.pending is not None, notice=ui.notice,
                forecast=forecast(ui.s, ui.kinds[ui.kind],
                                  -1 if ui.kinds[ui.kind] in ("wait", "drain") else ui.cursor))
    return view

def draw(p, view):
    p.cls(1)
    p.text(12, 10, "MISCHIEF GARDEN / STAGE 4", 10)
    screen = view["screen"]
    if screen != "board":
        p.rectb(12, 40, 360, 172, 10)
        if screen == "curtain":
            lines = ("HANDOFF / BOARD HIDDEN", "NEXT: "+view["role"].upper(),
                     "OTHER PLAYER: LOOK AWAY", "RELEASE KEYS, THEN ENTER",
                     "ROLES SWAPPED" if view["swapped"] else "TWO PEOPLE / ONE SCREEN",
                     "A PLAY CONVENTION, NOT SECURITY")
        elif screen == "help":
            lines = ("PATH FROM ENTRY / ROOT FROM NEST", "GARDENER: PATH, WATER, DIG*",
                     "MOLE: ROOT / TWO ACTIONS EACH", "AFTER MOLE: CONNECTED ROOTS DRAIN",
                     "ROUND 8: 3 BLOOMS -> GARDENER", "* DIG BEGINS IN STAGE 4",
                     "X CANCELS SELECTION, NEVER UNDO", "H CLOSE / BOARD IS HIDDEN")
        else:
            lines = ({"reset1": "GARDENER: AGREE TO RESET?",
                      "reset2": "MOLE: ALSO AGREE TO RESET?",
                      "quit": "QUIT THIS WINDOW?"}[screen],
                     "RELEASE, ENTER: YES / X: CANCEL",
                     "NO BOARD, PREVIEW OR HISTORY HERE")
        for i, line in enumerate(lines):
            p.text(24, 58+i*18, line, 7 if i else 10)
        return
    p.text(12, 26, view["role"].upper()+" / "+("SOLO LAB" if not view["round"] else
           "ROUND "+str(view["round"])+" / LEFT "+str(view["left"])), 7)
    for cell in range(25):
        x, y = 12+(cell%5)*34, 48+(cell//5)*34
        p.rect(x, y, 31, 31, 4)
        if cell in view["paths"]:
            p.rect(x+2, y+2, 27, 27, 13)
        if cell in ROCKS:
            p.rect(x+6, y+7, 19, 17, 5)
        if cell in view["roots"]:
            color = 10 if cell in view["network"] else 6
            p.circ(x+15, y+15, 3, color)
            for other in neighbors(cell):
                if other in view["roots"]:
                    p.line(x+15, y+15, x+15+(other%5-cell%5)*17,
                           y+15+(other//5-cell//5)*17, color)
        p.text(x+2, y+2, str(cell), 7)
        if cell in FLOWERS:
            water = view["water"][FLOWERS.index(cell)]
            p.circ(x+16, y+16, 5 if water==2 else 3, 14 if water else 5)
            p.text(x+20, y+23, str(water), 7)
        if cell == ENTRY:
            p.text(x+6, y+23, "IN", 1)
        if cell == NEST:
            p.text(x+7, y+23, "NEST", 7)
        if cell in view["drained"]:
            p.text(x+23, y+2, "D", 12)
        if cell == view["cursor"] and not view["winner"]:
            p.rectb(x-1, y-1, 33, 33, 7)
    p.text(194, 50, "BLOOMS "+str(view["water"].count(2))+" / 5", 10)
    p.text(194, 66, "ROOTS: "+("VISIBLE" if view["revealed"] else "HIDDEN"), 11)
    p.text(194, 82, "YELLOW: CONNECTED" if view["revealed"] else "PUBLIC WATER ONLY", 6)
    p.text(194, 94, "GRAY: DISCONNECTED" if view["revealed"] else "NO PRIVATE PREVIEW", 6)
    p.text(194, 112, "ACTION: "+view["kind"].upper(), 10)
    p.text(194, 128, "ENTER: COMMIT" if view["pending"] else "Z: SELECT", 7)
    p.text(194, 144, view["forecast"], 7)
    p.text(194, 160, "TICK "+str(view["tick"]), 6)
    p.text(194, 176, view["notice"], 11)
    if view["winner"]:
        p.text(194, 193, "WIN: "+view["winner"].upper(), 10)
        p.text(194, 205, "SPACE: SWAP AND REPLAY", 7)
    p.text(12, 228, "ARROWS CELL / TAB ACTION / Z SELECT / ENTER COMMIT", 7)
    p.text(12, 241, "X CANCEL / R RESET / H HELP* / ESC QUIT", 6)

CONTROL_KEYS = ("ESCAPE", "R", "H", "RETURN", "SPACE", "X", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "Z")
DEMO_ACTIONS = (('path', 5), ('path', 6), ('root', 13), ('root', 8), ('dig', 7))


def demo_controller():
    ui = Controller()
    if ui.curtain:
        tap(ui, "RETURN")
    for action in DEMO_ACTIONS:
        if ui.curtain:
            tap(ui, "RETURN")
        ui.kind = ui.kinds.index(action[0])
        ui.cursor = action[1] if action[1] >= 0 else ui.cursor
        tap(ui, "Z")
        tap(ui, "RETURN")

    return ui


def main(headless=False, screenshot=None, demo=False):
    # import時にウィンドウを開かない。モデルのテストにPyxelは不要。
    from pathlib import Path
    import pyxel as p
    output = Path(screenshot).resolve() if screenshot else None
    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
    ui = demo_controller() if demo else Controller()
    p.init(384, 256, title="Mischief Garden 4", fps=30,
           display_scale=2, quit_key=p.KEY_NONE, headless=headless)
    keymap = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    def update():
        if headless and p.frame_count >= 8:
            if output:
                p.screenshot(str(output), scale=2)
            p.quit()
            return
        result = ui.feed({key for key, value in keymap.items() if p.btn(value)})
        if result == "quit":
            p.quit()
    p.run(update, lambda: draw(p, scene(ui)))


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="いたずら庭師: 単体実行できる段階教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 05 / 05

持ちっぱなしと再試合の同意を扱う

入力の待ち方と、知ったことは戻せない状態の変化。

最後に、入力の安全さを仕上げます。gate は、1つのキー入力を処理したら閉じ、知っているキーが全部離されるまで、次の入力を受け付けません。Enter で2手目を確定した指が残っていても、同じ押し方で交代の幕を開けることはありません。押したままの Enter に Z を足し、あとで Enter だけ離す、という順番でも、全部離すまでは何も起きません。

同じフレームで2つ以上のキーが新しく押されたら、優先順位でどちらかを選ぶのではなく、reject:chord(同時押しは受け付けない)を返します。前半のステップの教材では、CONTROL_KEYS の順で1つのキーを選んでいました。05 では、R+Enter や左右の同時押しも両方受け付けません。操作が成功しない場合でも、全部離すことを求める作りです。

入力の分かれ道は「全部離すまで待つ → 同時押しを受け付けない → 確認の窓 → Esc → 説明 → R → 交代の幕 → 結果 → 盤」の順番です。確認の窓の中で H を押しても、盤の説明へは移りません。説明中の R も無視します。R は、庭師の確認、続いてモグラの確認、という2段階です。途中で X を押すとそのまま取り消し、2人とも Enter を押し終えると、最初の状態と庭師の幕に戻ります。これは2人が順番に確認する約束で、本人であることを技術的に確かめるものではありません。

X は最後まで、仮の選択の取り消しです。確定したあとの Undo は加えません。状態を過去に戻せても、相手の番で見たひみつや、掘って知ったことを、人の頭から消すことはできないからです。終わったときの Space で、役を交代して再戦できます。リセットや終了の確認の窓も画面全体を置きかえ、ひみつのカーソル・お知らせ・根・水・点数は読みません。

元のゲームをもとにした50の決まった手順・1,984の変化と、それぞれの状態での168通りの「できる手」を比べています。これは、すべての状態や作戦の公平さを証明するものではありません。実際の Pyxel での自動の描画と、ファイル単体での配布は確かめていますが、2人の人が使ったときの使いやすさ、のぞき見への強さ、ネットワーク対戦、音、ほかの OS での操作は確かめていません。

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

05_handoff.py / 注目する部分
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            return "reject:chord"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_handoff.py
macOS / Linux
.venv/bin/python 05_handoff.py
画面で見ること04 の手順で DIG 7 をしたあと、庭師が WAIT を選びます。モグラへの交代の幕だけが表示され、前のカーソル・予告・根・お知らせは残りません。Enter をいったん離してから、もう一度押すと、モグラの盤になります。
  • 全部のキーを離してから次へ
  • 2人が確認する前にリセットしない
  • 確定したあとの Undo は足さない
つまずきやすいところreset の中で gate を False にすると、確認に使った Enter が、新しい幕にそのまま流れこみやすくなります。最初に戻すゲームの状態と、実際に押されたキーの記録は分けておきます。
少し変えてみよう

庭師が1手実行したあとに、R → Enter と押してみましょう。まだ RESET の2人目の確認で、盤の状態は変わりません。X で取り消せます。もう一度 R → Enter → Enter と押すと、最初に戻ります。R と Enter を同時に押すと、確認の窓さえ開きません。

05_handoff.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(406行)
05_handoff.py / 全文
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""いたずら庭師: 日本語教材 5/5。LICENSEとLESSON.mdも読んでください。"""
from dataclasses import dataclass, replace

ENTRY = 10
NEST = 14
FLOWERS = (1, 11, 21, 8, 18)
ROCKS = (12,)


def neighbors(cell):
    """盤面の外へ出ず、左右端もつながない四近傍。"""
    x, y = cell % 5, cell // 5
    return tuple(ny * 5 + nx for nx, ny in
                 ((x-1, y), (x+1, y), (x, y-1), (x, y+1))
                 if 0 <= nx < 5 and 0 <= ny < 5)


def connected(cells, start):
    found = {start} if start in cells else set()
    frontier = list(found)
    while frontier:
        for cell in neighbors(frontier.pop()):
            if cell in cells and cell not in found:
                found.add(cell)
                frontier.append(cell)
    return frozenset(found)

@dataclass(frozen=True)
class State:
    paths: tuple = (ENTRY,)
    roots: tuple = (NEST,)
    water: tuple = (1, 1, 1, 1, 1)
    round: int = 1
    role: str = "gardener"
    left: int = 2
    drained: tuple = ()
    winner: str = ""
    tick: int = 0
    last: str = "READY"


def initial():
    return State()


def path_network(s):
    return connected(s.paths, ENTRY)


def root_network(s):
    return connected(s.roots, NEST)


def legal(s, kind, cell=-1):
    if s.winner:
        return False
    if kind == "wait":
        return True
    if cell not in range(25):
        return False
    if s.role == "gardener":
        network = path_network(s)
        near = cell in network or any(n in network for n in neighbors(cell))
        if kind == "path":
            return (cell not in (*s.paths, *FLOWERS, *ROCKS, NEST)
                    and any(n in network for n in neighbors(cell)))
        if kind == "water":
            return cell in FLOWERS and near and s.water[FLOWERS.index(cell)] < 2
        if kind == "dig":
            return near and cell not in (*ROCKS, NEST)
    elif s.role == "mole" and kind == "root":
        return (cell not in (*s.roots, *ROCKS)
                and any(n in root_network(s) for n in neighbors(cell)))
    return False


def step(s, action):
    kind, cell = action
    if not legal(s, kind, cell):
        return s
    paths, roots, water = set(s.paths), set(s.roots), list(s.water)
    if kind == "path":
        paths.add(cell)
    elif kind == "root":
        roots.add(cell)
    elif kind == "water":
        water[FLOWERS.index(cell)] += 1
    elif kind == "dig":
        roots.difference_update(q for q in (cell, *neighbors(cell)) if q != NEST)
    last = {"path": "PATH ADDED", "root": "UNDERGROUND WORK DONE",
            "water": "WATER ADDED", "wait": "WAITED"}.get(kind, "DIG DONE")
    if kind == "dig":
        last = "ROOT CUT" if set(s.roots)-roots else "NO ROOT"
    n = replace(s, paths=tuple(sorted(paths)), roots=tuple(sorted(roots)),
                water=tuple(water), left=s.left-1, tick=s.tick+1, last=last)
    if n.left:
        return n
    if n.role == "gardener":
        return replace(n, role="mole", left=2, last="HANDOFF")
    # 吸水はモグラの二手目の後だけ。全花壇を同じ連結集合で計算する。
    network = root_network(n)
    drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and water[i] > 0)
    water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, water))
    winner = ("gardener" if water.count(2) >= 3 else "mole") if n.round == 8 else ""
    return replace(n, water=water, drained=drained, role="gardener", left=2,
                   round=n.round if winner else n.round+1, winner=winner, last="DRAIN FINISHED")


def visible(s):
    # 庭師用辞書には根、根数、私的な最終操作を一切入れない。
    reveal = s.role == "mole" or bool(s.winner)
    return dict(paths=s.paths, water=s.water, round=s.round, role=s.role,
                left=s.left, drained=s.drained, winner=s.winner, tick=s.tick,
                roots=s.roots if reveal else (),
                network=root_network(s) if reveal else (), revealed=reveal)

def forecast(s, kind, cell):
    if not legal(s, kind, cell):
        return "BLOCKED / NO COST"
    # 掘る前に命中判定しない。庭師の予告は非公開の根に依存しない。
    return {"path": "EXTEND FROM ENTRY", "water": "WATER +1 / MAX 2",
            "root": "EXTEND FROM NEST", "dig": "DIG CENTER + 4 SIDES",
            "cut": "LAB: CUT CENTER + SIDES", "drain": "LAB: DRAIN CONNECTED",
            "wait": "SPEND ONE ACTION"}[kind]

class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.swapped = False
        self.reset()

    def reset(self):
        self.s = initial()
        self.cursor = ENTRY
        self.kind = 0
        self.pending = None
        self.notice = ""
        self.help = False
        self.dialog = ""
        self.curtain = True

    @property
    def kinds(self):
        return ("path", "water", "dig", "wait") if self.s.role == "gardener" else ("root", "wait")

    def commit(self, action):
        if self.curtain or self.help or self.dialog:
            return "blocked:overlay"
        before = self.s
        after = step(before, action)
        if after == before:
            return "reject:action"
        self.s = after
        self.pending = None
        self.notice = after.last
        if before.role != after.role and not after.winner:
            self.curtain = True
            self.kind = 0
            self.cursor = ENTRY if after.role == "gardener" else NEST
            self.notice = ""
            self.pending = None
        return "committed"

    def feed(self, down):
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            return "reject:chord"
        key = next(k for k in CONTROL_KEYS if k in fresh)
        self.notice = ""
        if self.dialog:
            if key in ("X", "ESCAPE"):
                self.dialog = ""
                return "cancel_dialog"
            if key == "RETURN":
                if self.dialog == "reset1":
                    self.dialog = "reset2"
                    return "confirm:second_player"
                if self.dialog == "reset2":
                    self.reset()
                    return "reset"
                if self.dialog == "quit":
                    return "quit"
            return "ignored:dialog"
        if key == "ESCAPE":
            self.dialog = "quit"
            return "confirm:quit"
        if key == "H":
            self.help = not self.help
            return "help"
        if self.help:
            return "ignored:help"
        if key == "R":
            self.dialog = "reset1"
            return "confirm:reset"
        if self.curtain:
            if key == "RETURN":
                self.curtain = False
                return "ready"
            return "ignored:curtain"
        if self.s.winner:
            if key == "SPACE":
                self.swapped = not self.swapped
                self.reset()
                return "swap_roles"
            return "ignored:result"
        if key == "TAB":
            self.kind = (self.kind+1) % len(self.kinds)
            self.pending = None
            return "kind"
        if key in ("LEFT", "RIGHT", "UP", "DOWN"):
            dx = {"LEFT": -1, "RIGHT": 1}.get(key, 0)
            dy = {"UP": -1, "DOWN": 1}.get(key, 0)
            self.cursor = ((self.cursor//5+dy)%5)*5 + (self.cursor%5+dx)%5
            self.pending = None
            return "cursor"
        if key == "X":
            self.pending = None
            return "cancel_selection"
        if key == "Z":
            kind = self.kinds[self.kind]
            action = (kind, -1 if kind in ("wait", "drain") else self.cursor)
            if legal(self.s, *action):
                self.pending = action
                return "selected"
            self.notice = "BLOCKED / NO COST"
            return "reject:selection"
        if key == "RETURN" and self.pending is not None:
            return self.commit(self.pending)
        return "ignored"


def tap(ui, *keys):
    result = ui.feed(set(keys))
    ui.feed(set())
    return result


def scene(ui):
    # 覆いの場面はここで返す。下の盤面や選択・通知を重ね描きしない。
    if ui.dialog:
        return {"screen": ui.dialog}
    if ui.help:
        return {"screen": "help"}
    if ui.curtain:
        return {"screen": "curtain", "role": ui.s.role, "swapped": ui.swapped}
    view = visible(ui.s)
    view.update(screen="board", cursor=ui.cursor, kind=ui.kinds[ui.kind],
                pending=ui.pending is not None, notice=ui.notice,
                forecast=forecast(ui.s, ui.kinds[ui.kind],
                                  -1 if ui.kinds[ui.kind] in ("wait", "drain") else ui.cursor))
    return view

def draw(p, view):
    p.cls(1)
    p.text(12, 10, "MISCHIEF GARDEN / STAGE 5", 10)
    screen = view["screen"]
    if screen != "board":
        p.rectb(12, 40, 360, 172, 10)
        if screen == "curtain":
            lines = ("HANDOFF / BOARD HIDDEN", "NEXT: "+view["role"].upper(),
                     "OTHER PLAYER: LOOK AWAY", "RELEASE KEYS, THEN ENTER",
                     "ROLES SWAPPED" if view["swapped"] else "TWO PEOPLE / ONE SCREEN",
                     "A PLAY CONVENTION, NOT SECURITY")
        elif screen == "help":
            lines = ("PATH FROM ENTRY / ROOT FROM NEST", "GARDENER: PATH, WATER, DIG*",
                     "MOLE: ROOT / TWO ACTIONS EACH", "AFTER MOLE: CONNECTED ROOTS DRAIN",
                     "ROUND 8: 3 BLOOMS -> GARDENER", "* DIG BEGINS IN STAGE 4",
                     "X CANCELS SELECTION, NEVER UNDO", "H CLOSE / BOARD IS HIDDEN")
        else:
            lines = ({"reset1": "GARDENER: AGREE TO RESET?",
                      "reset2": "MOLE: ALSO AGREE TO RESET?",
                      "quit": "QUIT THIS WINDOW?"}[screen],
                     "RELEASE, ENTER: YES / X: CANCEL",
                     "NO BOARD, PREVIEW OR HISTORY HERE")
        for i, line in enumerate(lines):
            p.text(24, 58+i*18, line, 7 if i else 10)
        return
    p.text(12, 26, view["role"].upper()+" / "+("SOLO LAB" if not view["round"] else
           "ROUND "+str(view["round"])+" / LEFT "+str(view["left"])), 7)
    for cell in range(25):
        x, y = 12+(cell%5)*34, 48+(cell//5)*34
        p.rect(x, y, 31, 31, 4)
        if cell in view["paths"]:
            p.rect(x+2, y+2, 27, 27, 13)
        if cell in ROCKS:
            p.rect(x+6, y+7, 19, 17, 5)
        if cell in view["roots"]:
            color = 10 if cell in view["network"] else 6
            p.circ(x+15, y+15, 3, color)
            for other in neighbors(cell):
                if other in view["roots"]:
                    p.line(x+15, y+15, x+15+(other%5-cell%5)*17,
                           y+15+(other//5-cell//5)*17, color)
        p.text(x+2, y+2, str(cell), 7)
        if cell in FLOWERS:
            water = view["water"][FLOWERS.index(cell)]
            p.circ(x+16, y+16, 5 if water==2 else 3, 14 if water else 5)
            p.text(x+20, y+23, str(water), 7)
        if cell == ENTRY:
            p.text(x+6, y+23, "IN", 1)
        if cell == NEST:
            p.text(x+7, y+23, "NEST", 7)
        if cell in view["drained"]:
            p.text(x+23, y+2, "D", 12)
        if cell == view["cursor"] and not view["winner"]:
            p.rectb(x-1, y-1, 33, 33, 7)
    p.text(194, 50, "BLOOMS "+str(view["water"].count(2))+" / 5", 10)
    p.text(194, 66, "ROOTS: "+("VISIBLE" if view["revealed"] else "HIDDEN"), 11)
    p.text(194, 82, "YELLOW: CONNECTED" if view["revealed"] else "PUBLIC WATER ONLY", 6)
    p.text(194, 94, "GRAY: DISCONNECTED" if view["revealed"] else "NO PRIVATE PREVIEW", 6)
    p.text(194, 112, "ACTION: "+view["kind"].upper(), 10)
    p.text(194, 128, "ENTER: COMMIT" if view["pending"] else "Z: SELECT", 7)
    p.text(194, 144, view["forecast"], 7)
    p.text(194, 160, "TICK "+str(view["tick"]), 6)
    p.text(194, 176, view["notice"], 11)
    if view["winner"]:
        p.text(194, 193, "WIN: "+view["winner"].upper(), 10)
        p.text(194, 205, "SPACE: SWAP AND REPLAY", 7)
    p.text(12, 228, "ARROWS CELL / TAB ACTION / Z SELECT / ENTER COMMIT", 7)
    p.text(12, 241, "X CANCEL / R RESET / H HELP* / ESC QUIT", 6)

CONTROL_KEYS = ("ESCAPE", "R", "H", "RETURN", "SPACE", "X", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "Z")
DEMO_ACTIONS = (('path', 5), ('path', 6), ('root', 13), ('root', 8), ('dig', 7), ('wait', -1))


def demo_controller():
    ui = Controller()
    if ui.curtain:
        tap(ui, "RETURN")
    for action in DEMO_ACTIONS:
        if ui.curtain:
            tap(ui, "RETURN")
        ui.kind = ui.kinds.index(action[0])
        ui.cursor = action[1] if action[1] >= 0 else ui.cursor
        tap(ui, "Z")
        tap(ui, "RETURN")

    return ui


def main(headless=False, screenshot=None, demo=False):
    # import時にウィンドウを開かない。モデルのテストにPyxelは不要。
    from pathlib import Path
    import pyxel as p
    output = Path(screenshot).resolve() if screenshot else None
    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
    ui = demo_controller() if demo else Controller()
    p.init(384, 256, title="Mischief Garden 5", fps=30,
           display_scale=2, quit_key=p.KEY_NONE, headless=headless)
    keymap = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS}
    def update():
        if headless and p.frame_count >= 8:
            if output:
                p.screenshot(str(output), scale=2)
            p.quit()
            return
        result = ui.feed({key for key, value in keymap.items() if p.btn(value)})
        if result == "quit":
            p.quit()
    p.run(update, lambda: draw(p, scene(ui)))


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="いたずら庭師: 単体実行できる段階教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
AFTER STEP 05

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

元のゲームの状況 A「五色の花壇」です。5×5の盤で、庭師とモグラがそれぞれ2回ずつ行動します。小道は入口10から、根は巣14から、上下左右でつながります。庭師は道・水・十字に掘る、モグラは根をのばします。モグラの2手のあと、つながった根が水を吸います。8ラウンド目の吸う処理のあとに、咲いた花が3つ以上なら庭師の勝ちです。

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

比べる点5ステップの教材元の完成版
段階01 は小道だけ、02 は根が全部見える1人用の実験、03 は DIG なしの一部の対戦、04 と 05 で元のゲームの状況 A です。A・B・C と、全部見える練習、ひみつの対戦があります。
表示・操作384×256 の文字(ASCII)、1つのファイルだけで動き、すぐ始まります。03 以降は幕、04 以降は H、05 は同時押しを受け付けないことと、2人のリセットの確認があります。960×720 の日本語の文字、表紙、状況の切りかえ、F12、常に記録する機能、B の全部見せる切りかえがあります。
秘密とUndoふつうに遊ぶときは、ひみつの記録を保存しません。確定したあとの Undo はなく、X は仮の選択の取り消しです。教材の fixture は、公開した決まった例です。確定したあとの Undo はありません。同じ画面や保存した記録について、技術的にひみつを守る保証はありません。
検証範囲標準ライブラリのテスト、元のゲームの決まった期待値、任意の比較、実際の Pyxel の自動画像、日本語とスペースが入ったフォルダーからの単体起動を確かめています。2人の人が使ったときの使いやすさは確かめていません。元のゲームの22のテストと自動デモは、別の確認です。人が遊んだ確認とは区別します。

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

  1. model.py: connected / path_network / root_network
  2. model.py: legal の庭師とモグラの分岐
  3. model.py: step の吸水と最終判定
  4. controller.py: feed のgate・dialog・curtain
  5. app.py: draw のdialog先行とreveal
自分のゲームに変えるなら

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

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