アクションLESSON 85同じ画面で遊ぶ、二役同時入力のかくれんぼ写真ゲーム原作087

かくれんぼ写真家の作り方

次のシャッターまでに、景色を変えよう。
2人の役を同じ「前の状態」から同時に動かし、次の刻(時間の区切り)の撮影を予約します。草に隠れて見えなくなる情報の境目、ゲームの終わり方を判定する順番、入力のための時計と、2人両方の確認を作ります。

完成版の遊び方同じキーボードで2人が、写真家と、姿を隠すことりに分かれて遊ぶ、庭の追いかけっこです。

かくれんぼ写真家の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Aの自動入力による描画です。下の01〜04はAを一刻ずつ進め、05でA/B/Cと原作の入力時計を扱います。人二人の実操作を確認した画像ではありません。
PLAY / 試験公開

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

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

完成版の主な操作:写真家: 矢印移動/M撮影/N方向。ことり: WASD移動/F仕掛け。P相談、Esc表紙

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

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

うまく表示されないとき

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

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

同時の移動と、次の刻の撮影を、別々に処理する。

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

1. 書き換えられない状態と、同時の入力、移動の待ち時間
2. あとで起こる出来事、予約したときの範囲と、判定するときの位置
3. 内部の状態と、画面に見せる情報を分ける・1回だけの行動
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple と set、番号(添字)・座標 / 書き換えない(frozen)dataclass と replace(本文で補足します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印 / WASD:写真家 / ことりの方向。キー割当は担当交代後も固定 / Space:01~04は一刻進む。05は結果後の担当交代と再試合 / M / N:02以降、撮影 / 右90度回転 / F:03以降、現在位置の景色を変更 / Tab:04以降、結果後だけ軌跡表示 / R:01~04は即初期化。05は双方確認の再試合相談 / 取消 / Z / Enter:05の再試合相談でことり / 写真家の同意 / Enter / 1 2 3 / B:05の表紙で開始 / 庭選択 / 波紋表示比較 / P / H:05で停止・再開 / Esc / Q:01~04は終了。05のEscは表紙、Qは終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_simultaneous.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

二役を同じ古い状態から動かす

書き換えられない状態と、同時の入力、移動の待ち時間。

最初は、7×5の庭に写真家とことりを置きます。写真家は矢印キー、ことりは WASD で動きます。方向キーを押しながら Space を1回押すと、1刻だけ進みます。次に進むには、Space を離して押し直します。方向を何も押さずに Space だけなら、その場で待ちます。01〜04 は動きを観察するための、止めて進める教材用の画面で、元のゲームのリアルタイムの対戦とは操作の時間が違います。

State は、f(写真家の座標)と h(ことりの座標)、facing(向き)、cd(移動の待ち時間)を持つ、書き換えない dataclass です。step は s を書き換えず、新しい State を返します。2人の移動先は、どちらも古い s から計算します。写真家を先に計算しても、ことりに写真家の新しい座標を渡しません。移動を止めるのは壁と庭の外だけで、2人が重なったり入れかわったりするのは禁止しません。相手の位置を壁として扱う追加のルールはありません。

DIR は、上・右・下・左の順に0〜3です。入力なしや、方向がぶつかる(同時に別々の方向を押す)ときは -1 です。片方の役が方向キーを2つ以上押したら、その役だけ動きません。もう片方の役の正しい方向はそのまま使われます。cd は移動が成功すると1になり、次の刻で0に減りますが、動けるかどうかは古い s.cd で判断するので、その刻にはまだ歩けません。そのため、方向キーを押し続けても、1歩進めるのは2刻ごとです。向きは、動けない刻にも変わります。

この段階には、撮影・草・景色の変更・出口・閉園(時間切れ)はありません。庭のデータにはそれらの座標も入っていますが、表示と判定には段階を追ってつないでいく作りです。ルールの座標と描画のピクセルを分け、draw は状態を読むだけにします。

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

01_simultaneous.py / 注目する部分
    if a.hdir in range(4) and s.cd[1] == 0:
        h = walk(g, s.h, a.hdir)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_simultaneous.py
macOS / Linux
.venv/bin/python 01_simultaneous.py
画面で見ること最初の状態で 上+W+Space を押すと、写真家 (0,1)、ことり (6,1)、TICK 1、MOVE COOLDOWN (1,1) です。Space を離して、同じ方向で2回目を押すと TICK 2 になりますが、座標は同じです。3回目で次の1歩を進みます。
  • 2人とも、同じ s を読む
  • 方向がぶつかったときは、役ごとに別々に処理する
  • 待ち時間の更新先と、判定に使う値を区別する
つまずきやすいところ先に cd を減らした値で動けるかを判断すると、毎刻歩けてしまいます。更新先の cd と、判定に使う s.cd を分けます。また、相手の新しい位置を通れない場所にすると、元のゲームにない「接触禁止」のルールになってしまいます。
少し変えてみよう

python 01_simultaneous.py --exercise を実行すると、[[1,0],[1,0]] と出ます。WALK_COOLDOWN = 1 を0に1か所変え、保存 → ウィンドウを閉じる → 実行し直すと、[[1,0],[2,0]] に変わります。この実験は、写真家を (0,0) に置いて右へ2刻動かす、試験用に作った条件です。確認したら1に戻します。R は読みこんだ状態を最初に戻すだけで、書き換えたコードを読み直しません。

01_simultaneous.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(349行)
01_simultaneous.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。説明は LESSON.md。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import hashlib, json

VERSION = '087-v1.0.1'

DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))

@dataclass(frozen=True)
class Garden:
    id: str
    title: str
    f: tuple
    h: tuple
    exit: tuple
    props: tuple
    walls: tuple
    grass: tuple

GARDENS = (Garden('A', '二つの植込み', (0, 2), (6, 2), (6, 4), ((1, 0), (3, 4), (5, 0)), ((2, 1), (2, 2), (4, 2), (4, 3)), ((3, 0), (4, 0), (5, 1), (3, 3), (3, 4))), Garden('B', '丸い池のまわり', (3, 0), (3, 4), (0, 4), ((0, 0), (6, 0), (6, 4)), ((2, 2), (3, 2), (4, 2)), ((0, 1), (0, 2), (1, 2), (5, 2), (6, 2), (6, 3))), Garden('C', '舞台袖', (0, 0), (6, 4), (6, 0), ((1, 3), (3, 0), (5, 3)), ((2, 0), (2, 1), (4, 3), (4, 4)), ((0, 3), (1, 3), (1, 4), (4, 1), (5, 1), (6, 1))))

@dataclass(frozen=True)
class Input:
    fdir: int = -1
    hdir: int = -1
    shoot: bool = False
    rotate: bool = False
    alter: bool = False

@dataclass(frozen=True)
class State:
    t: int = 0
    f: tuple = (0, 2)
    h: tuple = (6, 2)
    facing: int = 1
    cd: tuple = (0, 0)
    events: tuple = ()

WALK_COOLDOWN = 1

def new(g):
    return State(f=g.f, h=g.h)

def step(g, s, a):
    # 両者は同じ s を読む。先に動いた人の新しい位置は使わない。
    f, h, face = s.f, s.h, s.facing
    cd = [max(0, value - 1) for value in s.cd]
    events = []
    if a.fdir in range(4):
        face = a.fdir
        if s.cd[0] == 0:
            f = walk(g, s.f, a.fdir)
            if f != s.f:
                cd[0] = WALK_COOLDOWN
    if a.hdir in range(4) and s.cd[1] == 0:
        h = walk(g, s.h, a.hdir)
        if h != s.h:
            cd[1] = WALK_COOLDOWN
    return State(s.t + 1, f, h, face, tuple(cd), tuple(events))

def public(g, s, ripples=True, replay=False):
    return dict(t=s.t, f=s.f, h=s.h, hidden=False, facing=s.facing,
                mask=(), photo_wait=0, changed=(), ripples=(), events=s.events,
                result='', photo=(), trail=())

def walk(g, p, d):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    return q if 0 <= q[0] < 7 and 0 <= q[1] < 5 and (q not in g.walls) else p



# この境界から下が教材の表示・操作。modelと画面を別々にテストできる。
STAGE = 1
STAGE_TITLE = 'OLD STATE MOVEMENT'

def direction(keys, down):
    values = [i for i, key in enumerate(keys) if key in down]
    return values[0] if len(values) == 1 else -1

class App:
    def __init__(self):
        self.note = ''
        self.previous = set()
        self.replay = False
        if STAGE == 5:
            self.ui = Controller()
        else:
            self.state = new(GARDENS[0])

    @property
    def garden(self):
        return self.ui.garden if STAGE == 5 else GARDENS[0]

    def current(self):
        return self.ui.state if STAGE == 5 else self.state

    def control(self, down):
        if STAGE == 5:
            event = self.ui.feed(down)
            if event == 'screenshot':
                self.note = 'F12 EXPORT OMITTED; USE DOCUMENTED QA OPTIONS'
            return event
        fresh = set(down) - self.previous
        self.previous = set(down)
        if 'R' in fresh:
            self.state, self.replay = new(self.garden), False
            return 'reset'
        if STAGE >= 4 and 'TAB' in fresh and self.state.result:
            self.replay = not self.replay
        if 'SPACE' not in fresh:
            return None
        a = Input(direction(('UP','RIGHT','DOWN','LEFT'), down),
                  direction(('W','D','S','A'), down),
                  STAGE >= 2 and 'M' in down, STAGE >= 2 and 'N' in down,
                  STAGE >= 3 and 'F' in down)
        self.state = step(self.garden, self.state, a)
        return 'tick'

    def projection(self):
        replay = self.ui.replay if STAGE == 5 else self.replay
        ripples = not self.ui.variant if STAGE == 5 else True
        return public(self.garden, self.current(), ripples, replay)

    def draw(self, p):
        s, g, v = self.current(), self.garden, self.projection()
        p.cls(1)
        p.text(12,12,'087 HIDE PHOTO / STAGE '+str(STAGE)+' / '+STAGE_TITLE,10)
        p.text(12,29,'TICK '+str(s.t)+' / '+('CLOSE AT 72' if STAGE >= 4 else 'NO CLOSING RULE YET'),7)
        p.text(360,29,'GARDEN '+g.id+' / '+('REALTIME 4HZ' if STAGE == 5 else 'SPACE = ONE TICK'),11)
        ox, oy, size = 12, 52, 44
        for y in range(5):
            for x in range(7):
                pos = (x,y); px, py = ox+x*size, oy+y*size
                color = 5 if pos in g.walls else 3 if STAGE >= 3 and pos in g.grass else 4
                p.rect(px,py,size-2,size-2,color)
                if pos in g.walls:
                    for dy in (10,21,32):p.line(px+4,py+dy,px+37,py+dy,6)
                if STAGE >= 3 and pos in g.grass:
                    for dx in (9,21,33):p.line(px+dx,py+32,px+dx-4,py+15,11)
                if STAGE >= 3 and pos in g.props:
                    index = g.props.index(pos)
                    p.circ(px+21,py+22,9,10 if index in v['changed'] else 14)
                    p.text(px+4,py+4,str(index+1),7)
                    if index in v['changed']:p.text(px+17,py+19,'+',1)
                if STAGE >= 4 and pos == g.exit:
                    p.rectb(px+8,py+9,26,28,10);p.text(px+12,py+31,'EXIT',10)
        for pos in v['trail']:
            p.circ(ox+pos[0]*size+21,oy+pos[1]*size+21,2,14)
        for index, ticks in v['ripples']:
            x,y=g.props[index];p.circb(ox+x*size+21,oy+y*size+21,19,10)
        for x,y in v['mask']:
            p.rectb(ox+x*size+2,oy+y*size+2,size-6,size-6,7)
        x,y=v['f'];cx,cy=ox+x*size+21,oy+y*size+23
        p.rect(cx-10,cy-8,20,16,12);p.circ(cx,cy,5,7)
        dx,dy=DIR[v['facing']];p.line(cx,cy,cx+dx*18,cy+dy*18,10)
        if v['h'] is not None:
            x,y=v['h'];cx,cy=ox+x*size+22,oy+y*size+23
            p.circ(cx,cy,8,10);p.tri(cx+7,cy-2,cx+13,cy+2,cx+7,cy+4,9);p.pset(cx+3,cy-3,0)
        lines=[
            'PHOTOGRAPHER: ARROWS',
            'BIRD: W A S D',
            'CONTACT / SWAP ALLOWED',
            'MOVE COOLDOWN '+str(s.cd),
            '',
            'M: SHOOT / N: ROTATE' if STAGE >= 2 else 'ONLY MOVEMENT IN STAGE 1',
            'NEXT-TICK EXPOSURE' if STAGE >= 2 else 'NO SHUTTER / NO GRASS',
            'PHOTO WAIT '+str(v['photo_wait']),
            'DUE '+str(getattr(s,'due',-1)),
            'F: CHANGE SCENERY' if STAGE >= 3 else 'NO SCENERY ACTION YET',
            'CHANGED '+str(len(v['changed']))+' / 2',
            'BIRD HIDDEN IN GRASS' if v['hidden'] else 'BIRD VISIBLE',
            'REPLAY: POST-RESULT ONLY' if STAGE >= 4 else 'TRAIL NOT DISPLAYED',
            'RESULT '+(v['result'] or 'PLAYING'),
        ]
        for i,line in enumerate(lines):p.text(336,55+i*14,line,10 if i in (0,1,13) else 7)
        events={'写真家: 通れない':'PHOTOGRAPHER: BLOCKED','ことり: 足音':'BIRD: FOOTSTEP',
                'ことり: 通れない':'BIRD: BLOCKED','花が旗に':'FLOWER BECOMES FLAG',
                '雲が月に':'CLOUD BECOMES MOON','池が紙吹雪に':'POND BECOMES CONFETTI',
                '同じ景色は一度だけ':'SCENERY CHANGES ONCE','仕掛けの上で F':'USE F ON A PROP',
                '撮影と回転は一つずつ':'M + N CONFLICT: BIRD STILL ACTS',
                '白枠予告: 次の刻に撮影':'WHITE FRAME: EXPOSURE NEXT TICK',
                '写真を送っています':'SHUTTER IS COOLING DOWN','写った!':'PHOTO HIT: PHOTOGRAPHER WINS',
                '空の写真も一枚の景色':'PHOTO MISS','ふたつの景色を置いて、おかえり!':'TWO CHANGES + EXIT: BIRD WINS',
                '閉園。写真家の守りきり':'72 TICKS: PHOTOGRAPHER WINS'}
        for i,event in enumerate(v['events'][-3:]):p.text(12,289+i*15,events.get(event,'EVENT'),10)
        if v['result']:p.text(336,265,'WINNER: '+('BIRD' if v['result']=='H' else 'PHOTOGRAPHER'),10)
        if v['trail']:p.text(12,335,'POST-RESULT REPLAY / PINK DOTS = TRAIL',14)
        if STAGE == 5:
            p.text(12,358,'P/H: PAUSE / R: RESTART REQUEST / ESC: MENU / Q: QUIT',7)
            p.text(12,374,'RESULT: TAB REPLAY / SPACE CHANGE WHO PLAYS EACH ROLE',7)
            p.text(12,390,'ROLE KEYS STAY FIXED / RELEASE ALL KEYS AFTER TRANSITIONS',6)
            u=self.ui
            if u.title or u.paused or u.restart:
                p.rect(48,90,505,164,1);p.rectb(48,90,505,164,10)
                if u.title:
                    overlay=['START / ENTER (THEN RELEASE ALL KEYS)',
                        '1 / 2 / 3: GARDEN '+g.id, 'B: RIPPLES '+('OFF' if u.variant else 'ON'),
                        'ARROWS M N: PHOTOGRAPHER / WASD F: BIRD',
                        'TWO CHANGES + EXIT OR PHOTO / 72 TICKS']
                elif u.restart:
                    overlay=['RESTART REQUEST: WORLD IS STOPPED',
                        'Z: BIRD AGREES / ENTER: PHOTOGRAPHER AGREES',
                        'READY '+','.join(sorted(u.ready)), 'R: CANCEL / RELEASE ALL KEYS TO CONTINUE']
                else:overlay=['PAUSED: WORLD IS STOPPED','P / H: RESUME, THEN RELEASE ALL KEYS']
                for i,line in enumerate(overlay):p.text(63,108+i*25,line,10 if i==0 else 7)
            if u.swapped:p.text(336,280,'PLAYERS CHANGE ROLES; SAME KEYS',11)
        else:
            p.text(12,358,'HOLD ROLE KEYS, TAP SPACE, THEN RELEASE SPACE FOR NEXT TICK',7)
            p.text(12,374,'R: RESET LOADED STATE / ESC OR Q: QUIT',7)
            p.text(12,390,'SAVE CODE -> QUIT -> RERUN. R DOES NOT RELOAD THE FILE.',6)
        p.text(12,414,self.note or 'SYNTHETIC QA IS NOT A HUMAN TWO-PLAYER PLAYTEST.',6)

def checkpoint(app):
    if STAGE == 5:
        app.control({'RETURN'});app.control(set())
        for i in range(8):app.control({'UP','W','M'} if i == 0 else {'UP','W'})
    else:
        keys={'UP','W','SPACE'}
        if STAGE >= 2:keys.add('M')
        app.control(keys);app.control(set())

FIXTURES = ('conflict','photo_due','photo_escape','grass','grass_miss','ripples',
            'exit_priority','bird_win','timeout','replay_hidden','restart','role_swap')

def fixture(app, name):
    if STAGE != 5:raise ValueError('boundary fixtures require stage 5')
    u=app.ui;u.title=False;g=u.garden
    if name == 'conflict':
        for _ in range(8):app.control({'M','N','UP','DOWN','W'})
    elif name in ('photo_due','photo_escape'):
        u.state=replace(new(g),f=(0,0),h=(1,0))
        u.tick(Input(shoot=True))
        if name=='photo_escape':u.tick(Input(hdir=2))
    elif name in ('grass','grass_miss','replay_hidden'):
        u.state=replace(new(g),f=(2,0),h=(3,0),trail=((6,2),(6,1),(5,1),(4,1),(3,1),(3,0)))
        if name=='grass_miss':u.tick(Input(shoot=True));u.tick(Input())
        if name=='replay_hidden':u.state=replace(u.state,t=71);u.tick(Input());app.control({'TAB'})
    elif name=='ripples':
        u.state=replace(new(g),h=g.props[0]);u.tick(Input(alter=True,hdir=2))
    elif name=='exit_priority':
        u.state=replace(new(g),f=(6,3),h=(6,3),facing=2,changed=(0,1));u.tick(Input(shoot=True));u.tick(Input(hdir=2))
    elif name=='bird_win':
        u.state=replace(new(g),h=(6,3),changed=(0,1));u.tick(Input(hdir=2))
    elif name=='timeout':u.state=replace(new(g),t=71);u.tick(Input())
    elif name=='restart':app.control({'R'});app.control(set());app.control({'Z'})
    elif name=='role_swap':u.state=replace(new(g),result='H');app.control({'SPACE'});app.control(set())
    app.note='SYNTHETIC BOUNDARY FIXTURE: '+name.upper()

def exercise():
    g=GARDENS[0];s=replace(new(g),f=(0,0));s=step(g,s,Input(fdir=1));first=s.f;s=step(g,s,Input(fdir=1));return [first,s.f]

def output_path(parser, raw, extension):
    if raw is None:return None
    requested=Path(raw).expanduser()
    if requested.is_symlink():parser.error('output is a symlink; refusing')
    path=requested.resolve()
    if path.suffix != extension:parser.error('output extension must be '+extension)
    if not path.parent.is_dir():parser.error('output parent must already exist')
    if path.exists() or path.is_symlink():parser.error('output exists; refusing overwrite')
    return path

def main(args):
    # args の出力先は起動時 cwd 基準の絶対パス。Pyxel import/init より先に確定済み。
    import pyxel as p
    p.init(600,432,title='087 Hide photo lesson '+str(STAGE),fps=30,
           display_scale=1,headless=args.headless_smoke,quit_key=p.KEY_NONE)
    app=App()
    if args.demo_checkpoint:checkpoint(app)
    if args.fixture:fixture(app,args.fixture)
    names=('UP','RIGHT','DOWN','LEFT','W','D','S','A','M','N','F','SPACE',
           'TAB','R','P','H','Z','RETURN','1','2','3','B','F12','ESCAPE','Q')
    keys={key:getattr(p,'KEY_'+key) for key in names}
    def finish():
        app.draw(p)
        if args.screenshot:
            # save の上書き仕様へ依存しない。別名へ保存し、link の排他作成で確定する。
            import tempfile,os
            fd,temp=tempfile.mkstemp(suffix='.png',dir=args.screenshot.parent)
            os.close(fd)
            try:
                p.screen.save(temp,1)
                os.link(temp,args.screenshot)
            finally:Path(temp).unlink(missing_ok=True)
        if args.state_json:
            report={'stage':STAGE,'fixture':args.fixture,'demo':args.demo_checkpoint,
                    'label':'Actual Pyxel synthetic rendering; not human input',
                    'state':asdict(app.current()),'public':app.projection()}
            with args.state_json.open('x',encoding='utf-8') as handle:
                json.dump(report,handle,ensure_ascii=False,indent=2)
        p.quit()
    def update():
        if args.headless_smoke:
            if p.frame_count>=args.frames:finish()
            return
        down={key for key,value in keys.items() if p.btn(value)}
        if 'Q' in down or STAGE != 5 and 'ESCAPE' in down:finish();return
        app.control(down)
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--fixture',choices=FIXTURES)
    parser.add_argument('--frames',type=int,default=3)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--exercise',action='store_true')
    args=parser.parse_args()
    if args.exercise:
        if args.headless_smoke or args.demo_checkpoint or args.fixture or args.screenshot or args.state_json or args.frames != 3:
            parser.error('--exercise cannot be combined with rendering options')
        print(json.dumps(exercise(),ensure_ascii=True));raise SystemExit(0)
    if (args.demo_checkpoint or args.fixture or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    if args.fixture and (STAGE != 5 or args.demo_checkpoint):parser.error('fixture requires stage 5 without demo')
    if not 1<=args.frames<=300:parser.error('frames must be 1..300')
    args.screenshot=output_path(parser,args.screenshot,'.png')
    args.state_json=output_path(parser,args.state_json,'.json')
    main(args)
STEP 02 / 05

白枠を予約して、次の刻に撮る

あとで起こる出来事、予約したときの範囲と、判定するときの位置。

M を押しながら Space で、撮影を予約します。白い枠が出た刻には、まだ写りません。もう1刻進めると、その刻にことりが動いたあとの座標が、前の刻に予約した範囲の中にあるかを調べます。N は時計回りに90度回ります。M か N がある刻は、写真家は動きません。M と N を同時に押すと、どちらも実行しませんが、ことりはふつうに動けます。

mask は、写真家自身のマスから、向いている方向に最大3マス伸ばした範囲です。自分のマスも含むので、壁がなければ合計4マスです。walk が同じ場所を返したら、壁や庭の端に着いたと判断して止めます。exposure にはこの座標の列、due には撮影する刻を保存します。予約は tick+1、判定は s.due==tick で調べるので、同じ step の中で新しく予約した写真は、すぐには写りません。判定にも、新しい exp ではなく、古い s.exposure を使います。

写真家は、予約している間は動いたり向きを変えたりしません。ことりだけが、予約から判定までの間に逃げられます。判定したときに範囲の中にいれば result='F'(写真家の勝ち)。範囲の外なら空の写真になり、どちらの場合もゲームは続きます。写真を送るまでの待ち時間 photo_wait は、判定したときに3になり、毎刻減ります。撮影できるかは古い s.photo_wait で判断するので、表示が0になったその刻には、まだ予約できないことがあります。撮影の待ち時間と、移動の待ち時間は、別の時計です。

02 にはまだ草がありません。あとで草になる座標も、この段階ではふつうの床として写ります。出口と72刻の閉園もまだないので、写真家が写すまで終わりません。お手本の確認では、M+上+W を同時に入れ、写真家の移動が止められて、ことりだけが (6,1) へ動いた予約の状態を表示します。

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

02_queued_photo.py / 注目する部分
    if s.due == tick:
        shots += 1
        hit = h in s.exposure

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_queued_photo.py
macOS / Linux
.venv/bin/python 02_queued_photo.py
画面で見ることM+Space で白い枠と DUE 2 が出て、TICK 1 です。次の Space で1枚を判定し、DUE -1、PHOTO WAIT 3 になります。最初の位置では2人が遠いので、空の写真です。白い枠が壁で止まることも見てみましょう。
  • 予約と判定を、別の刻にする
  • 予約したときの白い枠を、そのまま固定する
  • 判定するときの、ことりの位置で当たりを調べる
つまずきやすいところ新しい due や exp で判定すると、予約した直後に写真ができあがってしまいます。また、当たりの判定に古い s.h を使うと、次の刻に白い枠から逃げたことりまで写ってしまいます。
少し変えてみよう

python 02_queued_photo.py --exercise で [[0,0],[1,0],[2,0],[3,0]] と出ます。PHOTO_REACH = 3 を1に変え、保存 → 閉じる → 実行し直すと [[0,0],[1,0]] です。写真家自身のマスを含むことは変わりません。これは、壁のない一番上の段から、範囲を確かめる実験です。確認したら3に戻します。

02_queued_photo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(388行)
02_queued_photo.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。説明は LESSON.md。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import hashlib, json

VERSION = '087-v1.0.1'

DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))

@dataclass(frozen=True)
class Garden:
    id: str
    title: str
    f: tuple
    h: tuple
    exit: tuple
    props: tuple
    walls: tuple
    grass: tuple

GARDENS = (Garden('A', '二つの植込み', (0, 2), (6, 2), (6, 4), ((1, 0), (3, 4), (5, 0)), ((2, 1), (2, 2), (4, 2), (4, 3)), ((3, 0), (4, 0), (5, 1), (3, 3), (3, 4))), Garden('B', '丸い池のまわり', (3, 0), (3, 4), (0, 4), ((0, 0), (6, 0), (6, 4)), ((2, 2), (3, 2), (4, 2)), ((0, 1), (0, 2), (1, 2), (5, 2), (6, 2), (6, 3))), Garden('C', '舞台袖', (0, 0), (6, 4), (6, 0), ((1, 3), (3, 0), (5, 3)), ((2, 0), (2, 1), (4, 3), (4, 4)), ((0, 3), (1, 3), (1, 4), (4, 1), (5, 1), (6, 1))))

@dataclass(frozen=True)
class Input:
    fdir: int = -1
    hdir: int = -1
    shoot: bool = False
    rotate: bool = False
    alter: bool = False

@dataclass(frozen=True)
class State:
    t: int = 0
    f: tuple = (0, 2)
    h: tuple = (6, 2)
    facing: int = 1
    cd: tuple = (0, 0)
    exposure: tuple = ()
    due: int = -1
    photo_wait: int = 0
    result: str = ''
    events: tuple = ()
    photo: tuple = ()
    shots: int = 0

def new(g):
    return State(f=g.f, h=g.h)

PHOTO_REACH = 3

def mask(g, p, d):
    cells = [p]
    for _ in range(PHOTO_REACH):
        q = walk(g, cells[-1], d)
        if q == cells[-1]:
            break
        cells.append(q)
    return tuple(cells)

def step(g, s, a):
    if s.result:
        return s
    tick = s.t + 1
    f, h, face = s.f, s.h, s.facing
    cd = [max(0, n - 1) for n in s.cd]
    exp, due = s.exposure, s.due
    wait = max(0, s.photo_wait - 1)
    photo, shots, result, events = s.photo, s.shots, '', []
    if s.due < 0 and not (a.shoot or a.rotate) and a.fdir in range(4):
        face = a.fdir
        if s.cd[0] == 0:
            f = walk(g, s.f, a.fdir)
            if f != s.f:
                cd[0] = 1
    if a.hdir in range(4) and s.cd[1] == 0:
        h = walk(g, s.h, a.hdir)
        if h != s.h:
            cd[1] = 1
    if s.due < 0:
        if a.shoot and a.rotate:
            events.append('撮影と回転は一つずつ')
        elif a.rotate:
            face = (face + 1) % 4
        elif a.shoot:
            if s.photo_wait == 0:
                exp, due = mask(g, f, face), tick + 1
                events.append('白枠予告: 次の刻に撮影')
            else:
                events.append('写真を送っています')
    # 先ほどの exp でなく s.exposure を使う。予約した刻には撮らない。
    if s.due == tick:
        shots += 1
        hit = h in s.exposure
        photo = (tick, s.exposure, h if hit else None, hit)
        result = 'F' if hit else ''
        events.append('写った!' if hit else '空の写真も一枚の景色')
        exp, due, wait = (), -1, 3
    return State(tick, f, h, face, tuple(cd), exp, due, wait,
                 result, tuple(events), photo, shots)

def public(g, s, ripples=True, replay=False):
    return dict(t=s.t, f=s.f, h=s.h, hidden=False, facing=s.facing,
                mask=s.exposure, photo_wait=s.photo_wait, changed=(), ripples=(),
                events=s.events, result=s.result, photo=s.photo, trail=())

def walk(g, p, d):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    return q if 0 <= q[0] < 7 and 0 <= q[1] < 5 and (q not in g.walls) else p



# この境界から下が教材の表示・操作。modelと画面を別々にテストできる。
STAGE = 2
STAGE_TITLE = 'NEXT-TICK SHUTTER'

def direction(keys, down):
    values = [i for i, key in enumerate(keys) if key in down]
    return values[0] if len(values) == 1 else -1

class App:
    def __init__(self):
        self.note = ''
        self.previous = set()
        self.replay = False
        if STAGE == 5:
            self.ui = Controller()
        else:
            self.state = new(GARDENS[0])

    @property
    def garden(self):
        return self.ui.garden if STAGE == 5 else GARDENS[0]

    def current(self):
        return self.ui.state if STAGE == 5 else self.state

    def control(self, down):
        if STAGE == 5:
            event = self.ui.feed(down)
            if event == 'screenshot':
                self.note = 'F12 EXPORT OMITTED; USE DOCUMENTED QA OPTIONS'
            return event
        fresh = set(down) - self.previous
        self.previous = set(down)
        if 'R' in fresh:
            self.state, self.replay = new(self.garden), False
            return 'reset'
        if STAGE >= 4 and 'TAB' in fresh and self.state.result:
            self.replay = not self.replay
        if 'SPACE' not in fresh:
            return None
        a = Input(direction(('UP','RIGHT','DOWN','LEFT'), down),
                  direction(('W','D','S','A'), down),
                  STAGE >= 2 and 'M' in down, STAGE >= 2 and 'N' in down,
                  STAGE >= 3 and 'F' in down)
        self.state = step(self.garden, self.state, a)
        return 'tick'

    def projection(self):
        replay = self.ui.replay if STAGE == 5 else self.replay
        ripples = not self.ui.variant if STAGE == 5 else True
        return public(self.garden, self.current(), ripples, replay)

    def draw(self, p):
        s, g, v = self.current(), self.garden, self.projection()
        p.cls(1)
        p.text(12,12,'087 HIDE PHOTO / STAGE '+str(STAGE)+' / '+STAGE_TITLE,10)
        p.text(12,29,'TICK '+str(s.t)+' / '+('CLOSE AT 72' if STAGE >= 4 else 'NO CLOSING RULE YET'),7)
        p.text(360,29,'GARDEN '+g.id+' / '+('REALTIME 4HZ' if STAGE == 5 else 'SPACE = ONE TICK'),11)
        ox, oy, size = 12, 52, 44
        for y in range(5):
            for x in range(7):
                pos = (x,y); px, py = ox+x*size, oy+y*size
                color = 5 if pos in g.walls else 3 if STAGE >= 3 and pos in g.grass else 4
                p.rect(px,py,size-2,size-2,color)
                if pos in g.walls:
                    for dy in (10,21,32):p.line(px+4,py+dy,px+37,py+dy,6)
                if STAGE >= 3 and pos in g.grass:
                    for dx in (9,21,33):p.line(px+dx,py+32,px+dx-4,py+15,11)
                if STAGE >= 3 and pos in g.props:
                    index = g.props.index(pos)
                    p.circ(px+21,py+22,9,10 if index in v['changed'] else 14)
                    p.text(px+4,py+4,str(index+1),7)
                    if index in v['changed']:p.text(px+17,py+19,'+',1)
                if STAGE >= 4 and pos == g.exit:
                    p.rectb(px+8,py+9,26,28,10);p.text(px+12,py+31,'EXIT',10)
        for pos in v['trail']:
            p.circ(ox+pos[0]*size+21,oy+pos[1]*size+21,2,14)
        for index, ticks in v['ripples']:
            x,y=g.props[index];p.circb(ox+x*size+21,oy+y*size+21,19,10)
        for x,y in v['mask']:
            p.rectb(ox+x*size+2,oy+y*size+2,size-6,size-6,7)
        x,y=v['f'];cx,cy=ox+x*size+21,oy+y*size+23
        p.rect(cx-10,cy-8,20,16,12);p.circ(cx,cy,5,7)
        dx,dy=DIR[v['facing']];p.line(cx,cy,cx+dx*18,cy+dy*18,10)
        if v['h'] is not None:
            x,y=v['h'];cx,cy=ox+x*size+22,oy+y*size+23
            p.circ(cx,cy,8,10);p.tri(cx+7,cy-2,cx+13,cy+2,cx+7,cy+4,9);p.pset(cx+3,cy-3,0)
        lines=[
            'PHOTOGRAPHER: ARROWS',
            'BIRD: W A S D',
            'CONTACT / SWAP ALLOWED',
            'MOVE COOLDOWN '+str(s.cd),
            '',
            'M: SHOOT / N: ROTATE' if STAGE >= 2 else 'ONLY MOVEMENT IN STAGE 1',
            'NEXT-TICK EXPOSURE' if STAGE >= 2 else 'NO SHUTTER / NO GRASS',
            'PHOTO WAIT '+str(v['photo_wait']),
            'DUE '+str(getattr(s,'due',-1)),
            'F: CHANGE SCENERY' if STAGE >= 3 else 'NO SCENERY ACTION YET',
            'CHANGED '+str(len(v['changed']))+' / 2',
            'BIRD HIDDEN IN GRASS' if v['hidden'] else 'BIRD VISIBLE',
            'REPLAY: POST-RESULT ONLY' if STAGE >= 4 else 'TRAIL NOT DISPLAYED',
            'RESULT '+(v['result'] or 'PLAYING'),
        ]
        for i,line in enumerate(lines):p.text(336,55+i*14,line,10 if i in (0,1,13) else 7)
        events={'写真家: 通れない':'PHOTOGRAPHER: BLOCKED','ことり: 足音':'BIRD: FOOTSTEP',
                'ことり: 通れない':'BIRD: BLOCKED','花が旗に':'FLOWER BECOMES FLAG',
                '雲が月に':'CLOUD BECOMES MOON','池が紙吹雪に':'POND BECOMES CONFETTI',
                '同じ景色は一度だけ':'SCENERY CHANGES ONCE','仕掛けの上で F':'USE F ON A PROP',
                '撮影と回転は一つずつ':'M + N CONFLICT: BIRD STILL ACTS',
                '白枠予告: 次の刻に撮影':'WHITE FRAME: EXPOSURE NEXT TICK',
                '写真を送っています':'SHUTTER IS COOLING DOWN','写った!':'PHOTO HIT: PHOTOGRAPHER WINS',
                '空の写真も一枚の景色':'PHOTO MISS','ふたつの景色を置いて、おかえり!':'TWO CHANGES + EXIT: BIRD WINS',
                '閉園。写真家の守りきり':'72 TICKS: PHOTOGRAPHER WINS'}
        for i,event in enumerate(v['events'][-3:]):p.text(12,289+i*15,events.get(event,'EVENT'),10)
        if v['result']:p.text(336,265,'WINNER: '+('BIRD' if v['result']=='H' else 'PHOTOGRAPHER'),10)
        if v['trail']:p.text(12,335,'POST-RESULT REPLAY / PINK DOTS = TRAIL',14)
        if STAGE == 5:
            p.text(12,358,'P/H: PAUSE / R: RESTART REQUEST / ESC: MENU / Q: QUIT',7)
            p.text(12,374,'RESULT: TAB REPLAY / SPACE CHANGE WHO PLAYS EACH ROLE',7)
            p.text(12,390,'ROLE KEYS STAY FIXED / RELEASE ALL KEYS AFTER TRANSITIONS',6)
            u=self.ui
            if u.title or u.paused or u.restart:
                p.rect(48,90,505,164,1);p.rectb(48,90,505,164,10)
                if u.title:
                    overlay=['START / ENTER (THEN RELEASE ALL KEYS)',
                        '1 / 2 / 3: GARDEN '+g.id, 'B: RIPPLES '+('OFF' if u.variant else 'ON'),
                        'ARROWS M N: PHOTOGRAPHER / WASD F: BIRD',
                        'TWO CHANGES + EXIT OR PHOTO / 72 TICKS']
                elif u.restart:
                    overlay=['RESTART REQUEST: WORLD IS STOPPED',
                        'Z: BIRD AGREES / ENTER: PHOTOGRAPHER AGREES',
                        'READY '+','.join(sorted(u.ready)), 'R: CANCEL / RELEASE ALL KEYS TO CONTINUE']
                else:overlay=['PAUSED: WORLD IS STOPPED','P / H: RESUME, THEN RELEASE ALL KEYS']
                for i,line in enumerate(overlay):p.text(63,108+i*25,line,10 if i==0 else 7)
            if u.swapped:p.text(336,280,'PLAYERS CHANGE ROLES; SAME KEYS',11)
        else:
            p.text(12,358,'HOLD ROLE KEYS, TAP SPACE, THEN RELEASE SPACE FOR NEXT TICK',7)
            p.text(12,374,'R: RESET LOADED STATE / ESC OR Q: QUIT',7)
            p.text(12,390,'SAVE CODE -> QUIT -> RERUN. R DOES NOT RELOAD THE FILE.',6)
        p.text(12,414,self.note or 'SYNTHETIC QA IS NOT A HUMAN TWO-PLAYER PLAYTEST.',6)

def checkpoint(app):
    if STAGE == 5:
        app.control({'RETURN'});app.control(set())
        for i in range(8):app.control({'UP','W','M'} if i == 0 else {'UP','W'})
    else:
        keys={'UP','W','SPACE'}
        if STAGE >= 2:keys.add('M')
        app.control(keys);app.control(set())

FIXTURES = ('conflict','photo_due','photo_escape','grass','grass_miss','ripples',
            'exit_priority','bird_win','timeout','replay_hidden','restart','role_swap')

def fixture(app, name):
    if STAGE != 5:raise ValueError('boundary fixtures require stage 5')
    u=app.ui;u.title=False;g=u.garden
    if name == 'conflict':
        for _ in range(8):app.control({'M','N','UP','DOWN','W'})
    elif name in ('photo_due','photo_escape'):
        u.state=replace(new(g),f=(0,0),h=(1,0))
        u.tick(Input(shoot=True))
        if name=='photo_escape':u.tick(Input(hdir=2))
    elif name in ('grass','grass_miss','replay_hidden'):
        u.state=replace(new(g),f=(2,0),h=(3,0),trail=((6,2),(6,1),(5,1),(4,1),(3,1),(3,0)))
        if name=='grass_miss':u.tick(Input(shoot=True));u.tick(Input())
        if name=='replay_hidden':u.state=replace(u.state,t=71);u.tick(Input());app.control({'TAB'})
    elif name=='ripples':
        u.state=replace(new(g),h=g.props[0]);u.tick(Input(alter=True,hdir=2))
    elif name=='exit_priority':
        u.state=replace(new(g),f=(6,3),h=(6,3),facing=2,changed=(0,1));u.tick(Input(shoot=True));u.tick(Input(hdir=2))
    elif name=='bird_win':
        u.state=replace(new(g),h=(6,3),changed=(0,1));u.tick(Input(hdir=2))
    elif name=='timeout':u.state=replace(new(g),t=71);u.tick(Input())
    elif name=='restart':app.control({'R'});app.control(set());app.control({'Z'})
    elif name=='role_swap':u.state=replace(new(g),result='H');app.control({'SPACE'});app.control(set())
    app.note='SYNTHETIC BOUNDARY FIXTURE: '+name.upper()

def exercise():
    return mask(GARDENS[0],(0,0),1)

def output_path(parser, raw, extension):
    if raw is None:return None
    requested=Path(raw).expanduser()
    if requested.is_symlink():parser.error('output is a symlink; refusing')
    path=requested.resolve()
    if path.suffix != extension:parser.error('output extension must be '+extension)
    if not path.parent.is_dir():parser.error('output parent must already exist')
    if path.exists() or path.is_symlink():parser.error('output exists; refusing overwrite')
    return path

def main(args):
    # args の出力先は起動時 cwd 基準の絶対パス。Pyxel import/init より先に確定済み。
    import pyxel as p
    p.init(600,432,title='087 Hide photo lesson '+str(STAGE),fps=30,
           display_scale=1,headless=args.headless_smoke,quit_key=p.KEY_NONE)
    app=App()
    if args.demo_checkpoint:checkpoint(app)
    if args.fixture:fixture(app,args.fixture)
    names=('UP','RIGHT','DOWN','LEFT','W','D','S','A','M','N','F','SPACE',
           'TAB','R','P','H','Z','RETURN','1','2','3','B','F12','ESCAPE','Q')
    keys={key:getattr(p,'KEY_'+key) for key in names}
    def finish():
        app.draw(p)
        if args.screenshot:
            # save の上書き仕様へ依存しない。別名へ保存し、link の排他作成で確定する。
            import tempfile,os
            fd,temp=tempfile.mkstemp(suffix='.png',dir=args.screenshot.parent)
            os.close(fd)
            try:
                p.screen.save(temp,1)
                os.link(temp,args.screenshot)
            finally:Path(temp).unlink(missing_ok=True)
        if args.state_json:
            report={'stage':STAGE,'fixture':args.fixture,'demo':args.demo_checkpoint,
                    'label':'Actual Pyxel synthetic rendering; not human input',
                    'state':asdict(app.current()),'public':app.projection()}
            with args.state_json.open('x',encoding='utf-8') as handle:
                json.dump(report,handle,ensure_ascii=False,indent=2)
        p.quit()
    def update():
        if args.headless_smoke:
            if p.frame_count>=args.frames:finish()
            return
        down={key for key,value in keys.items() if p.btn(value)}
        if 'Q' in down or STAGE != 5 and 'ESCAPE' in down:finish();return
        app.control(down)
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--fixture',choices=FIXTURES)
    parser.add_argument('--frames',type=int,default=3)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--exercise',action='store_true')
    args=parser.parse_args()
    if args.exercise:
        if args.headless_smoke or args.demo_checkpoint or args.fixture or args.screenshot or args.state_json or args.frames != 3:
            parser.error('--exercise cannot be combined with rendering options')
        print(json.dumps(exercise(),ensure_ascii=True));raise SystemExit(0)
    if (args.demo_checkpoint or args.fixture or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    if args.fixture and (STAGE != 5 or args.demo_checkpoint):parser.error('fixture requires stage 5 without demo')
    if not 1<=args.frames<=300:parser.error('frames must be 1..300')
    args.screenshot=output_path(parser,args.screenshot,'.png')
    args.state_json=output_path(parser,args.state_json,'.json')
    main(args)
STEP 03 / 05

姿を隠し、景色の変化を公開する

内部の状態と、画面に見せる情報を分ける・1回だけの行動。

草に入ったことりは、2人どちらの画面からも消え、白い枠の中にいても写りません。State.h の座標そのものはあり、ゲームを進めるために使います。public は、ふつうに草にいるときの h を None にし、ふつうの途中の足あと trail も空にして、表示に渡します。描画で s.h を直接見ないことで、草の上に居場所の印を出しません。これは同じ画面で遊ぶための表示の工夫で、コードやメモリーからも情報を隠すセキュリティの機能ではありません。

番号のついた景色の上で F+Space を押すと、その景色を1回だけ変えられます。3か所の仕掛けは、花→旗、雲→月、池→紙吹雪です。教材の画面は見た目を簡単にしていて、ピンクの丸が、黄色い+つきの丸になり、下の英語の出来事の表示も変わります。元のゲームの3種類の絵そのものは再現していません。changed は変えた景色の番号を重ならないように記録し、同じ仕掛けで何回 F を押しても、数は増えません。F を押した刻には、ことりは動かないので、移動した先の仕掛けを同時に変えることはできません。

変えた直後に、ripples(波紋)に (景色の番号,2) を加えます。次の刻に残り1、その次には消えます。波紋は景色の場所に出る、相手にも見える手がかりで、草の上のことりの位置の印ではありません。草の中にある景色を変えても、ことりの姿は消えたままですが、景色の変化と波紋は相手に見えます。changed はずっと残る記録、ripples は少しの間だけの表示、という役割の違いがあります。

この段階では、草・景色の変更・波紋を加えますが、「2つ変えて出口へ帰る」勝ちと閉園は、次の段階で加えます。撮影の成功による写真家の勝ちだけは、02 から残っています。結果のあとにすべての足あとを見る画面の操作も、まだありません。操作と表示を少しずつ足しながら、ルールのどの部分をつないだかを確かめましょう。

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

03_grass_ripples.py / 注目する部分
        hit = h in s.exposure and h not in g.grass

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_grass_ripples.py
macOS / Linux
.venv/bin/python 03_grass_ripples.py
画面で見ること草に入ると BIRD HIDDEN IN GRASS(ことりは草に隠れている)になり、黄色いことりが消えます。仕掛けの上で F を押すと、CHANGED が1になります。変えた景色の黄色い輪は、変えた直後と次の刻に見え、その次には消えます。
  • 内部の h と、見せる h を分ける
  • 同じ景色は、1回だけ変えられる
  • ずっと残る changed と、短い間だけの ripples を区別する
つまずきやすいところことりを消すだけでは、足あとや別の位置の印から居場所がわかってしまいます。画面には、public の h と trail を使います。一方で、波紋まで消すと、元のゲームの「相手にも見える手がかり」を消してしまいます。
少し変えてみよう

python 03_grass_ripples.py --exercise は [[[0,2]],[[0,1]]] と出ます。RIPPLE_TICKS = 2 を4に変えて、保存 → 閉じる → 実行し直すと [[[0,4]],[[0,3]]] です。試験用の最初の状態で仕掛け0を変え、次の刻に減っていくのを観察します。changed の数と、草の判定は変えません。確認したら2に戻します。

03_grass_ripples.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(417行)
03_grass_ripples.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。説明は LESSON.md。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import hashlib, json

VERSION = '087-v1.0.1'

DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))

@dataclass(frozen=True)
class Garden:
    id: str
    title: str
    f: tuple
    h: tuple
    exit: tuple
    props: tuple
    walls: tuple
    grass: tuple

GARDENS = (Garden('A', '二つの植込み', (0, 2), (6, 2), (6, 4), ((1, 0), (3, 4), (5, 0)), ((2, 1), (2, 2), (4, 2), (4, 3)), ((3, 0), (4, 0), (5, 1), (3, 3), (3, 4))), Garden('B', '丸い池のまわり', (3, 0), (3, 4), (0, 4), ((0, 0), (6, 0), (6, 4)), ((2, 2), (3, 2), (4, 2)), ((0, 1), (0, 2), (1, 2), (5, 2), (6, 2), (6, 3))), Garden('C', '舞台袖', (0, 0), (6, 4), (6, 0), ((1, 3), (3, 0), (5, 3)), ((2, 0), (2, 1), (4, 3), (4, 4)), ((0, 3), (1, 3), (1, 4), (4, 1), (5, 1), (6, 1))))

@dataclass(frozen=True)
class Input:
    fdir: int = -1
    hdir: int = -1
    shoot: bool = False
    rotate: bool = False
    alter: bool = False

@dataclass(frozen=True)
class State:
    t: int = 0
    f: tuple = (0, 2)
    h: tuple = (6, 2)
    facing: int = 1
    cd: tuple = (0, 0)
    exposure: tuple = ()
    due: int = -1
    photo_wait: int = 0
    changed: tuple = ()
    ripples: tuple = ()
    result: str = ''
    events: tuple = ()
    photo: tuple = ()
    trail: tuple = ()
    shots: int = 0

def new(g):
    return State(f=g.f, h=g.h, trail=(g.h,))

def walk(g, p, d):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    return q if 0 <= q[0] < 7 and 0 <= q[1] < 5 and (q not in g.walls) else p

def mask(g, p, d):
    cells = [p]
    for _ in range(3):
        q = walk(g, cells[-1], d)
        if q == cells[-1]:
            break
        cells.append(q)
    return tuple(cells)

RIPPLE_TICKS = 2

def step(g, s, a):
    if s.result:
        return s
    tick = s.t + 1
    events = []
    f = s.f
    h = s.h
    face = s.facing
    cd = [max(0, n - 1) for n in s.cd]
    changed = s.changed
    ripple = tuple(((i, n - 1) for i, n in s.ripples if n > 1))
    exp = s.exposure
    due = s.due
    wait = max(0, s.photo_wait - 1)
    photo = s.photo
    shots = s.shots
    result = ''
    if s.due < 0 and (not (a.shoot or a.rotate)) and (a.fdir in range(4)):
        face = a.fdir
        if s.cd[0] == 0:
            f = walk(g, s.f, a.fdir)
            if f != s.f:
                cd[0] = 1
            else:
                events.append('写真家: 通れない')
    if not a.alter and a.hdir in range(4) and (s.cd[1] == 0):
        h = walk(g, s.h, a.hdir)
        if h != s.h:
            cd[1] = 1
            events.append('ことり: 足音')
        else:
            events.append('ことり: 通れない')
    if a.alter:
        if h in g.props:
            i = g.props.index(h)
            if i not in changed:
                changed = tuple(sorted(changed + (i,)))
                events.append(('花が旗に', '雲が月に', '池が紙吹雪に')[i])
                ripple = tuple(((j, n) for j, n in ripple if j != i)) + ((i, RIPPLE_TICKS),)
            else:
                events.append('同じ景色は一度だけ')
        else:
            events.append('仕掛けの上で F')
    if s.due < 0:
        if a.shoot and a.rotate:
            events.append('撮影と回転は一つずつ')
        elif a.rotate:
            face = (face + 1) % 4
        elif a.shoot:
            if s.photo_wait == 0:
                exp = mask(g, f, face)
                due = tick + 1
                events.append('白枠予告: 次の刻に撮影')
            else:
                events.append('写真を送っています')
    if s.due == tick:
        shots += 1
        hit = h in s.exposure and h not in g.grass
        photo = (tick, s.exposure, h if hit else None, hit)
        events.append('写った!' if hit else '空の写真も一枚の景色')
        result = 'F' if hit else ''
        exp = ()
        due = -1
        wait = 3
    return State(tick, f, h, face, tuple(cd), exp, due, wait, changed, ripple, result, tuple(events), photo, s.trail + (h,), shots)

def public(g, s, ripples=True, replay=False):
    return {'t': s.t, 'f': s.f, 'h': s.h if s.h not in g.grass or (replay and s.result) else None, 'hidden': s.h in g.grass, 'facing': s.facing, 'mask': s.exposure, 'photo_wait': s.photo_wait, 'changed': s.changed, 'ripples': s.ripples if ripples else (), 'events': s.events, 'result': s.result, 'photo': s.photo, 'trail': s.trail if replay and s.result else ()}

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()



# この境界から下が教材の表示・操作。modelと画面を別々にテストできる。
STAGE = 3
STAGE_TITLE = 'GRASS AND CLUES'

def direction(keys, down):
    values = [i for i, key in enumerate(keys) if key in down]
    return values[0] if len(values) == 1 else -1

class App:
    def __init__(self):
        self.note = ''
        self.previous = set()
        self.replay = False
        if STAGE == 5:
            self.ui = Controller()
        else:
            self.state = new(GARDENS[0])

    @property
    def garden(self):
        return self.ui.garden if STAGE == 5 else GARDENS[0]

    def current(self):
        return self.ui.state if STAGE == 5 else self.state

    def control(self, down):
        if STAGE == 5:
            event = self.ui.feed(down)
            if event == 'screenshot':
                self.note = 'F12 EXPORT OMITTED; USE DOCUMENTED QA OPTIONS'
            return event
        fresh = set(down) - self.previous
        self.previous = set(down)
        if 'R' in fresh:
            self.state, self.replay = new(self.garden), False
            return 'reset'
        if STAGE >= 4 and 'TAB' in fresh and self.state.result:
            self.replay = not self.replay
        if 'SPACE' not in fresh:
            return None
        a = Input(direction(('UP','RIGHT','DOWN','LEFT'), down),
                  direction(('W','D','S','A'), down),
                  STAGE >= 2 and 'M' in down, STAGE >= 2 and 'N' in down,
                  STAGE >= 3 and 'F' in down)
        self.state = step(self.garden, self.state, a)
        return 'tick'

    def projection(self):
        replay = self.ui.replay if STAGE == 5 else self.replay
        ripples = not self.ui.variant if STAGE == 5 else True
        return public(self.garden, self.current(), ripples, replay)

    def draw(self, p):
        s, g, v = self.current(), self.garden, self.projection()
        p.cls(1)
        p.text(12,12,'087 HIDE PHOTO / STAGE '+str(STAGE)+' / '+STAGE_TITLE,10)
        p.text(12,29,'TICK '+str(s.t)+' / '+('CLOSE AT 72' if STAGE >= 4 else 'NO CLOSING RULE YET'),7)
        p.text(360,29,'GARDEN '+g.id+' / '+('REALTIME 4HZ' if STAGE == 5 else 'SPACE = ONE TICK'),11)
        ox, oy, size = 12, 52, 44
        for y in range(5):
            for x in range(7):
                pos = (x,y); px, py = ox+x*size, oy+y*size
                color = 5 if pos in g.walls else 3 if STAGE >= 3 and pos in g.grass else 4
                p.rect(px,py,size-2,size-2,color)
                if pos in g.walls:
                    for dy in (10,21,32):p.line(px+4,py+dy,px+37,py+dy,6)
                if STAGE >= 3 and pos in g.grass:
                    for dx in (9,21,33):p.line(px+dx,py+32,px+dx-4,py+15,11)
                if STAGE >= 3 and pos in g.props:
                    index = g.props.index(pos)
                    p.circ(px+21,py+22,9,10 if index in v['changed'] else 14)
                    p.text(px+4,py+4,str(index+1),7)
                    if index in v['changed']:p.text(px+17,py+19,'+',1)
                if STAGE >= 4 and pos == g.exit:
                    p.rectb(px+8,py+9,26,28,10);p.text(px+12,py+31,'EXIT',10)
        for pos in v['trail']:
            p.circ(ox+pos[0]*size+21,oy+pos[1]*size+21,2,14)
        for index, ticks in v['ripples']:
            x,y=g.props[index];p.circb(ox+x*size+21,oy+y*size+21,19,10)
        for x,y in v['mask']:
            p.rectb(ox+x*size+2,oy+y*size+2,size-6,size-6,7)
        x,y=v['f'];cx,cy=ox+x*size+21,oy+y*size+23
        p.rect(cx-10,cy-8,20,16,12);p.circ(cx,cy,5,7)
        dx,dy=DIR[v['facing']];p.line(cx,cy,cx+dx*18,cy+dy*18,10)
        if v['h'] is not None:
            x,y=v['h'];cx,cy=ox+x*size+22,oy+y*size+23
            p.circ(cx,cy,8,10);p.tri(cx+7,cy-2,cx+13,cy+2,cx+7,cy+4,9);p.pset(cx+3,cy-3,0)
        lines=[
            'PHOTOGRAPHER: ARROWS',
            'BIRD: W A S D',
            'CONTACT / SWAP ALLOWED',
            'MOVE COOLDOWN '+str(s.cd),
            '',
            'M: SHOOT / N: ROTATE' if STAGE >= 2 else 'ONLY MOVEMENT IN STAGE 1',
            'NEXT-TICK EXPOSURE' if STAGE >= 2 else 'NO SHUTTER / NO GRASS',
            'PHOTO WAIT '+str(v['photo_wait']),
            'DUE '+str(getattr(s,'due',-1)),
            'F: CHANGE SCENERY' if STAGE >= 3 else 'NO SCENERY ACTION YET',
            'CHANGED '+str(len(v['changed']))+' / 2',
            'BIRD HIDDEN IN GRASS' if v['hidden'] else 'BIRD VISIBLE',
            'REPLAY: POST-RESULT ONLY' if STAGE >= 4 else 'TRAIL NOT DISPLAYED',
            'RESULT '+(v['result'] or 'PLAYING'),
        ]
        for i,line in enumerate(lines):p.text(336,55+i*14,line,10 if i in (0,1,13) else 7)
        events={'写真家: 通れない':'PHOTOGRAPHER: BLOCKED','ことり: 足音':'BIRD: FOOTSTEP',
                'ことり: 通れない':'BIRD: BLOCKED','花が旗に':'FLOWER BECOMES FLAG',
                '雲が月に':'CLOUD BECOMES MOON','池が紙吹雪に':'POND BECOMES CONFETTI',
                '同じ景色は一度だけ':'SCENERY CHANGES ONCE','仕掛けの上で F':'USE F ON A PROP',
                '撮影と回転は一つずつ':'M + N CONFLICT: BIRD STILL ACTS',
                '白枠予告: 次の刻に撮影':'WHITE FRAME: EXPOSURE NEXT TICK',
                '写真を送っています':'SHUTTER IS COOLING DOWN','写った!':'PHOTO HIT: PHOTOGRAPHER WINS',
                '空の写真も一枚の景色':'PHOTO MISS','ふたつの景色を置いて、おかえり!':'TWO CHANGES + EXIT: BIRD WINS',
                '閉園。写真家の守りきり':'72 TICKS: PHOTOGRAPHER WINS'}
        for i,event in enumerate(v['events'][-3:]):p.text(12,289+i*15,events.get(event,'EVENT'),10)
        if v['result']:p.text(336,265,'WINNER: '+('BIRD' if v['result']=='H' else 'PHOTOGRAPHER'),10)
        if v['trail']:p.text(12,335,'POST-RESULT REPLAY / PINK DOTS = TRAIL',14)
        if STAGE == 5:
            p.text(12,358,'P/H: PAUSE / R: RESTART REQUEST / ESC: MENU / Q: QUIT',7)
            p.text(12,374,'RESULT: TAB REPLAY / SPACE CHANGE WHO PLAYS EACH ROLE',7)
            p.text(12,390,'ROLE KEYS STAY FIXED / RELEASE ALL KEYS AFTER TRANSITIONS',6)
            u=self.ui
            if u.title or u.paused or u.restart:
                p.rect(48,90,505,164,1);p.rectb(48,90,505,164,10)
                if u.title:
                    overlay=['START / ENTER (THEN RELEASE ALL KEYS)',
                        '1 / 2 / 3: GARDEN '+g.id, 'B: RIPPLES '+('OFF' if u.variant else 'ON'),
                        'ARROWS M N: PHOTOGRAPHER / WASD F: BIRD',
                        'TWO CHANGES + EXIT OR PHOTO / 72 TICKS']
                elif u.restart:
                    overlay=['RESTART REQUEST: WORLD IS STOPPED',
                        'Z: BIRD AGREES / ENTER: PHOTOGRAPHER AGREES',
                        'READY '+','.join(sorted(u.ready)), 'R: CANCEL / RELEASE ALL KEYS TO CONTINUE']
                else:overlay=['PAUSED: WORLD IS STOPPED','P / H: RESUME, THEN RELEASE ALL KEYS']
                for i,line in enumerate(overlay):p.text(63,108+i*25,line,10 if i==0 else 7)
            if u.swapped:p.text(336,280,'PLAYERS CHANGE ROLES; SAME KEYS',11)
        else:
            p.text(12,358,'HOLD ROLE KEYS, TAP SPACE, THEN RELEASE SPACE FOR NEXT TICK',7)
            p.text(12,374,'R: RESET LOADED STATE / ESC OR Q: QUIT',7)
            p.text(12,390,'SAVE CODE -> QUIT -> RERUN. R DOES NOT RELOAD THE FILE.',6)
        p.text(12,414,self.note or 'SYNTHETIC QA IS NOT A HUMAN TWO-PLAYER PLAYTEST.',6)

def checkpoint(app):
    if STAGE == 5:
        app.control({'RETURN'});app.control(set())
        for i in range(8):app.control({'UP','W','M'} if i == 0 else {'UP','W'})
    else:
        keys={'UP','W','SPACE'}
        if STAGE >= 2:keys.add('M')
        app.control(keys);app.control(set())

FIXTURES = ('conflict','photo_due','photo_escape','grass','grass_miss','ripples',
            'exit_priority','bird_win','timeout','replay_hidden','restart','role_swap')

def fixture(app, name):
    if STAGE != 5:raise ValueError('boundary fixtures require stage 5')
    u=app.ui;u.title=False;g=u.garden
    if name == 'conflict':
        for _ in range(8):app.control({'M','N','UP','DOWN','W'})
    elif name in ('photo_due','photo_escape'):
        u.state=replace(new(g),f=(0,0),h=(1,0))
        u.tick(Input(shoot=True))
        if name=='photo_escape':u.tick(Input(hdir=2))
    elif name in ('grass','grass_miss','replay_hidden'):
        u.state=replace(new(g),f=(2,0),h=(3,0),trail=((6,2),(6,1),(5,1),(4,1),(3,1),(3,0)))
        if name=='grass_miss':u.tick(Input(shoot=True));u.tick(Input())
        if name=='replay_hidden':u.state=replace(u.state,t=71);u.tick(Input());app.control({'TAB'})
    elif name=='ripples':
        u.state=replace(new(g),h=g.props[0]);u.tick(Input(alter=True,hdir=2))
    elif name=='exit_priority':
        u.state=replace(new(g),f=(6,3),h=(6,3),facing=2,changed=(0,1));u.tick(Input(shoot=True));u.tick(Input(hdir=2))
    elif name=='bird_win':
        u.state=replace(new(g),h=(6,3),changed=(0,1));u.tick(Input(hdir=2))
    elif name=='timeout':u.state=replace(new(g),t=71);u.tick(Input())
    elif name=='restart':app.control({'R'});app.control(set());app.control({'Z'})
    elif name=='role_swap':u.state=replace(new(g),result='H');app.control({'SPACE'});app.control(set())
    app.note='SYNTHETIC BOUNDARY FIXTURE: '+name.upper()

def exercise():
    g=GARDENS[0];s=replace(new(g),h=g.props[0]);s=step(g,s,Input(alter=True));first=s.ripples;s=step(g,s,Input());return [first,s.ripples]

def output_path(parser, raw, extension):
    if raw is None:return None
    requested=Path(raw).expanduser()
    if requested.is_symlink():parser.error('output is a symlink; refusing')
    path=requested.resolve()
    if path.suffix != extension:parser.error('output extension must be '+extension)
    if not path.parent.is_dir():parser.error('output parent must already exist')
    if path.exists() or path.is_symlink():parser.error('output exists; refusing overwrite')
    return path

def main(args):
    # args の出力先は起動時 cwd 基準の絶対パス。Pyxel import/init より先に確定済み。
    import pyxel as p
    p.init(600,432,title='087 Hide photo lesson '+str(STAGE),fps=30,
           display_scale=1,headless=args.headless_smoke,quit_key=p.KEY_NONE)
    app=App()
    if args.demo_checkpoint:checkpoint(app)
    if args.fixture:fixture(app,args.fixture)
    names=('UP','RIGHT','DOWN','LEFT','W','D','S','A','M','N','F','SPACE',
           'TAB','R','P','H','Z','RETURN','1','2','3','B','F12','ESCAPE','Q')
    keys={key:getattr(p,'KEY_'+key) for key in names}
    def finish():
        app.draw(p)
        if args.screenshot:
            # save の上書き仕様へ依存しない。別名へ保存し、link の排他作成で確定する。
            import tempfile,os
            fd,temp=tempfile.mkstemp(suffix='.png',dir=args.screenshot.parent)
            os.close(fd)
            try:
                p.screen.save(temp,1)
                os.link(temp,args.screenshot)
            finally:Path(temp).unlink(missing_ok=True)
        if args.state_json:
            report={'stage':STAGE,'fixture':args.fixture,'demo':args.demo_checkpoint,
                    'label':'Actual Pyxel synthetic rendering; not human input',
                    'state':asdict(app.current()),'public':app.projection()}
            with args.state_json.open('x',encoding='utf-8') as handle:
                json.dump(report,handle,ensure_ascii=False,indent=2)
        p.quit()
    def update():
        if args.headless_smoke:
            if p.frame_count>=args.frames:finish()
            return
        down={key for key,value in keys.items() if p.btn(value)}
        if 'Q' in down or STAGE != 5 and 'ESCAPE' in down:finish();return
        app.control(down)
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--fixture',choices=FIXTURES)
    parser.add_argument('--frames',type=int,default=3)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--exercise',action='store_true')
    args=parser.parse_args()
    if args.exercise:
        if args.headless_smoke or args.demo_checkpoint or args.fixture or args.screenshot or args.state_json or args.frames != 3:
            parser.error('--exercise cannot be combined with rendering options')
        print(json.dumps(exercise(),ensure_ascii=True));raise SystemExit(0)
    if (args.demo_checkpoint or args.fixture or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    if args.fixture and (STAGE != 5 or args.demo_checkpoint):parser.error('fixture requires stage 5 without demo')
    if not 1<=args.frames<=300:parser.error('frames must be 1..300')
    args.screenshot=output_path(parser,args.screenshot,'.png')
    args.state_json=output_path(parser,args.state_json,'.json')
    main(args)
STEP 04 / 05

帰還と閉園を、撮影の後に判定する

ゲームの終わりを判定する順番と、終わったあとだけのリプレイ。

ちがう景色を2つ以上変えて出口に着くと、ことりの勝ちです。変える景色は3つ全部ではなく、2つで足ります。写真家は、写真に写すか、72刻まで守りきれば勝ちです。判定は、写真の判定 → ことりの出口 → 72刻の閉園、の順です。同じ刻にいくつもの条件を満たすことがあるので、この順番もルールの一部です。

step では、まず写真に写ったか(hit)から result='F' を決めます。そのあとの出口と閉園は、if not result で囲みます。たとえば72刻目に、ことりが出口に着いたのと同時に、予約された写真に写ったなら、写真家の勝ちです。写真に写らず、2つの景色を変えたことりが72刻目に出口に着けば、閉園より先に、ことりの勝ちです。時間切れだけを先に処理すると、この違いが失われます。

result がもう決まっていれば、step はその State を返し、終わったあとの入力で盤面を動かしません。終わったあとだけ、Tab で足あとの表示を切り替えます。public の replay=True と s.result の両方が必要です。草にいたことりの最後の座標と、途中のすべての足あとをピンクの点で示すので、ふつうの対戦中とは見せる情報が変わります。途中で Tab を押しても見せません。R で同じ庭の最初の状態に戻ります。この段階の R は教材用のすぐやり直す機能で、元のゲームの「2人が同意して再試合」は 05 で加えます。

04 には、元のゲームの model.py の定義と、庭 A/B/C のデータが入っていますが、画面で選べるのは A だけで、時間は Space で1刻ずつ進みます。元のゲームのルールを全部持っていることと、元のゲームと同じ画面の操作を再現することは、分けて考えます。Undo は元のゲームにないので、追加していません。

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

04_exit_priority.py / 注目する部分
    if not result and len(changed) >= 2 and (h == g.exit):
        result = 'H'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_exit_priority.py
macOS / Linux
.venv/bin/python 04_exit_priority.py
画面で見ることpython 04_exit_priority.py --exercise で tick 72、result F、photo true と出ます。これは、出口に着く・撮影・閉園が同じ刻に起きる、試験用に作った境目の例です。ふつうの画面の最初の状態から、何もしないで勝てるという意味ではありません。
  • 写真 → 出口 → 閉園、の優先順位
  • 終わった状態は、それ以上動かさない
  • 足あとを見せるのは、結果が出たあとだけ
つまずきやすいところ3つの勝ちの条件を、それぞれ独立した if で上書きすると、最後の閉園の判定が、先に決まった勝者を消してしまいます。出口だけを先に判定しても、同じ刻なら写真が優先、というルールを守れません。
少し変えてみよう

exercise 関数の reserve_photo = True を False に1か所変え、保存 → 閉じる → python 04_exit_priority.py --exercise で実行し直してみましょう。tick 72 は同じで、result H、photo false に変わります。写真を予約しないという条件だけを変えて、出口が閉園より優先されることを確かめます。元に戻すと F です。これは、確認用の最初の状態を作る実験で、ゲーム本体の勝ちの条件を書き換えるものではありません。

04_exit_priority.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(421行)
04_exit_priority.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。説明は LESSON.md。Python 3.11+ / Pyxel 2.9.9。"""
"""Pure deterministic simultaneous photography toy. No renderer or filesystem."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '087-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))

@dataclass(frozen=True)
class Garden:
    id: str
    title: str
    f: tuple
    h: tuple
    exit: tuple
    props: tuple
    walls: tuple
    grass: tuple
GARDENS = (Garden('A', '二つの植込み', (0, 2), (6, 2), (6, 4), ((1, 0), (3, 4), (5, 0)), ((2, 1), (2, 2), (4, 2), (4, 3)), ((3, 0), (4, 0), (5, 1), (3, 3), (3, 4))), Garden('B', '丸い池のまわり', (3, 0), (3, 4), (0, 4), ((0, 0), (6, 0), (6, 4)), ((2, 2), (3, 2), (4, 2)), ((0, 1), (0, 2), (1, 2), (5, 2), (6, 2), (6, 3))), Garden('C', '舞台袖', (0, 0), (6, 4), (6, 0), ((1, 3), (3, 0), (5, 3)), ((2, 0), (2, 1), (4, 3), (4, 4)), ((0, 3), (1, 3), (1, 4), (4, 1), (5, 1), (6, 1))))

@dataclass(frozen=True)
class Input:
    fdir: int = -1
    hdir: int = -1
    shoot: bool = False
    rotate: bool = False
    alter: bool = False

@dataclass(frozen=True)
class State:
    t: int = 0
    f: tuple = (0, 2)
    h: tuple = (6, 2)
    facing: int = 1
    cd: tuple = (0, 0)
    exposure: tuple = ()
    due: int = -1
    photo_wait: int = 0
    changed: tuple = ()
    ripples: tuple = ()
    result: str = ''
    events: tuple = ()
    photo: tuple = ()
    trail: tuple = ()
    shots: int = 0

def new(g):
    return State(f=g.f, h=g.h, trail=(g.h,))

def walk(g, p, d):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    return q if 0 <= q[0] < 7 and 0 <= q[1] < 5 and (q not in g.walls) else p

def mask(g, p, d):
    cells = [p]
    for _ in range(3):
        q = walk(g, cells[-1], d)
        if q == cells[-1]:
            break
        cells.append(q)
    return tuple(cells)

def step(g, s, a):
    if s.result:
        return s
    tick = s.t + 1
    events = []
    f = s.f
    h = s.h
    face = s.facing
    cd = [max(0, n - 1) for n in s.cd]
    changed = s.changed
    ripple = tuple(((i, n - 1) for i, n in s.ripples if n > 1))
    exp = s.exposure
    due = s.due
    wait = max(0, s.photo_wait - 1)
    photo = s.photo
    shots = s.shots
    result = ''
    if s.due < 0 and (not (a.shoot or a.rotate)) and (a.fdir in range(4)):
        face = a.fdir
        if s.cd[0] == 0:
            f = walk(g, s.f, a.fdir)
            if f != s.f:
                cd[0] = 1
            else:
                events.append('写真家: 通れない')
    if not a.alter and a.hdir in range(4) and (s.cd[1] == 0):
        h = walk(g, s.h, a.hdir)
        if h != s.h:
            cd[1] = 1
            events.append('ことり: 足音')
        else:
            events.append('ことり: 通れない')
    if a.alter:
        if h in g.props:
            i = g.props.index(h)
            if i not in changed:
                changed = tuple(sorted(changed + (i,)))
                events.append(('花が旗に', '雲が月に', '池が紙吹雪に')[i])
                ripple = tuple(((j, n) for j, n in ripple if j != i)) + ((i, 2),)
            else:
                events.append('同じ景色は一度だけ')
        else:
            events.append('仕掛けの上で F')
    if s.due < 0:
        if a.shoot and a.rotate:
            events.append('撮影と回転は一つずつ')
        elif a.rotate:
            face = (face + 1) % 4
        elif a.shoot:
            if s.photo_wait == 0:
                exp = mask(g, f, face)
                due = tick + 1
                events.append('白枠予告: 次の刻に撮影')
            else:
                events.append('写真を送っています')
    if s.due == tick:
        shots += 1
        hit = h in s.exposure and h not in g.grass
        photo = (tick, s.exposure, h if hit else None, hit)
        events.append('写った!' if hit else '空の写真も一枚の景色')
        result = 'F' if hit else ''
        exp = ()
        due = -1
        wait = 3
    if not result and len(changed) >= 2 and (h == g.exit):
        result = 'H'
        events.append('ふたつの景色を置いて、おかえり!')
    if not result and tick >= 72:
        result = 'F'
        events.append('閉園。写真家の守りきり')
    return State(tick, f, h, face, tuple(cd), exp, due, wait, changed, ripple, result, tuple(events), photo, s.trail + (h,), shots)

def public(g, s, ripples=True, replay=False):
    return {'t': s.t, 'f': s.f, 'h': s.h if s.h not in g.grass or (replay and s.result) else None, 'hidden': s.h in g.grass, 'facing': s.facing, 'mask': s.exposure, 'photo_wait': s.photo_wait, 'changed': s.changed, 'ripples': s.ripples if ripples else (), 'events': s.events, 'result': s.result, 'photo': s.photo, 'trail': s.trail if replay and s.result else ()}

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()


# この境界から下が教材の表示・操作。modelと画面を別々にテストできる。
STAGE = 4
STAGE_TITLE = 'EXIT AND CLOSING'

def direction(keys, down):
    values = [i for i, key in enumerate(keys) if key in down]
    return values[0] if len(values) == 1 else -1

class App:
    def __init__(self):
        self.note = ''
        self.previous = set()
        self.replay = False
        if STAGE == 5:
            self.ui = Controller()
        else:
            self.state = new(GARDENS[0])

    @property
    def garden(self):
        return self.ui.garden if STAGE == 5 else GARDENS[0]

    def current(self):
        return self.ui.state if STAGE == 5 else self.state

    def control(self, down):
        if STAGE == 5:
            event = self.ui.feed(down)
            if event == 'screenshot':
                self.note = 'F12 EXPORT OMITTED; USE DOCUMENTED QA OPTIONS'
            return event
        fresh = set(down) - self.previous
        self.previous = set(down)
        if 'R' in fresh:
            self.state, self.replay = new(self.garden), False
            return 'reset'
        if STAGE >= 4 and 'TAB' in fresh and self.state.result:
            self.replay = not self.replay
        if 'SPACE' not in fresh:
            return None
        a = Input(direction(('UP','RIGHT','DOWN','LEFT'), down),
                  direction(('W','D','S','A'), down),
                  STAGE >= 2 and 'M' in down, STAGE >= 2 and 'N' in down,
                  STAGE >= 3 and 'F' in down)
        self.state = step(self.garden, self.state, a)
        return 'tick'

    def projection(self):
        replay = self.ui.replay if STAGE == 5 else self.replay
        ripples = not self.ui.variant if STAGE == 5 else True
        return public(self.garden, self.current(), ripples, replay)

    def draw(self, p):
        s, g, v = self.current(), self.garden, self.projection()
        p.cls(1)
        p.text(12,12,'087 HIDE PHOTO / STAGE '+str(STAGE)+' / '+STAGE_TITLE,10)
        p.text(12,29,'TICK '+str(s.t)+' / '+('CLOSE AT 72' if STAGE >= 4 else 'NO CLOSING RULE YET'),7)
        p.text(360,29,'GARDEN '+g.id+' / '+('REALTIME 4HZ' if STAGE == 5 else 'SPACE = ONE TICK'),11)
        ox, oy, size = 12, 52, 44
        for y in range(5):
            for x in range(7):
                pos = (x,y); px, py = ox+x*size, oy+y*size
                color = 5 if pos in g.walls else 3 if STAGE >= 3 and pos in g.grass else 4
                p.rect(px,py,size-2,size-2,color)
                if pos in g.walls:
                    for dy in (10,21,32):p.line(px+4,py+dy,px+37,py+dy,6)
                if STAGE >= 3 and pos in g.grass:
                    for dx in (9,21,33):p.line(px+dx,py+32,px+dx-4,py+15,11)
                if STAGE >= 3 and pos in g.props:
                    index = g.props.index(pos)
                    p.circ(px+21,py+22,9,10 if index in v['changed'] else 14)
                    p.text(px+4,py+4,str(index+1),7)
                    if index in v['changed']:p.text(px+17,py+19,'+',1)
                if STAGE >= 4 and pos == g.exit:
                    p.rectb(px+8,py+9,26,28,10);p.text(px+12,py+31,'EXIT',10)
        for pos in v['trail']:
            p.circ(ox+pos[0]*size+21,oy+pos[1]*size+21,2,14)
        for index, ticks in v['ripples']:
            x,y=g.props[index];p.circb(ox+x*size+21,oy+y*size+21,19,10)
        for x,y in v['mask']:
            p.rectb(ox+x*size+2,oy+y*size+2,size-6,size-6,7)
        x,y=v['f'];cx,cy=ox+x*size+21,oy+y*size+23
        p.rect(cx-10,cy-8,20,16,12);p.circ(cx,cy,5,7)
        dx,dy=DIR[v['facing']];p.line(cx,cy,cx+dx*18,cy+dy*18,10)
        if v['h'] is not None:
            x,y=v['h'];cx,cy=ox+x*size+22,oy+y*size+23
            p.circ(cx,cy,8,10);p.tri(cx+7,cy-2,cx+13,cy+2,cx+7,cy+4,9);p.pset(cx+3,cy-3,0)
        lines=[
            'PHOTOGRAPHER: ARROWS',
            'BIRD: W A S D',
            'CONTACT / SWAP ALLOWED',
            'MOVE COOLDOWN '+str(s.cd),
            '',
            'M: SHOOT / N: ROTATE' if STAGE >= 2 else 'ONLY MOVEMENT IN STAGE 1',
            'NEXT-TICK EXPOSURE' if STAGE >= 2 else 'NO SHUTTER / NO GRASS',
            'PHOTO WAIT '+str(v['photo_wait']),
            'DUE '+str(getattr(s,'due',-1)),
            'F: CHANGE SCENERY' if STAGE >= 3 else 'NO SCENERY ACTION YET',
            'CHANGED '+str(len(v['changed']))+' / 2',
            'BIRD HIDDEN IN GRASS' if v['hidden'] else 'BIRD VISIBLE',
            'REPLAY: POST-RESULT ONLY' if STAGE >= 4 else 'TRAIL NOT DISPLAYED',
            'RESULT '+(v['result'] or 'PLAYING'),
        ]
        for i,line in enumerate(lines):p.text(336,55+i*14,line,10 if i in (0,1,13) else 7)
        events={'写真家: 通れない':'PHOTOGRAPHER: BLOCKED','ことり: 足音':'BIRD: FOOTSTEP',
                'ことり: 通れない':'BIRD: BLOCKED','花が旗に':'FLOWER BECOMES FLAG',
                '雲が月に':'CLOUD BECOMES MOON','池が紙吹雪に':'POND BECOMES CONFETTI',
                '同じ景色は一度だけ':'SCENERY CHANGES ONCE','仕掛けの上で F':'USE F ON A PROP',
                '撮影と回転は一つずつ':'M + N CONFLICT: BIRD STILL ACTS',
                '白枠予告: 次の刻に撮影':'WHITE FRAME: EXPOSURE NEXT TICK',
                '写真を送っています':'SHUTTER IS COOLING DOWN','写った!':'PHOTO HIT: PHOTOGRAPHER WINS',
                '空の写真も一枚の景色':'PHOTO MISS','ふたつの景色を置いて、おかえり!':'TWO CHANGES + EXIT: BIRD WINS',
                '閉園。写真家の守りきり':'72 TICKS: PHOTOGRAPHER WINS'}
        for i,event in enumerate(v['events'][-3:]):p.text(12,289+i*15,events.get(event,'EVENT'),10)
        if v['result']:p.text(336,265,'WINNER: '+('BIRD' if v['result']=='H' else 'PHOTOGRAPHER'),10)
        if v['trail']:p.text(12,335,'POST-RESULT REPLAY / PINK DOTS = TRAIL',14)
        if STAGE == 5:
            p.text(12,358,'P/H: PAUSE / R: RESTART REQUEST / ESC: MENU / Q: QUIT',7)
            p.text(12,374,'RESULT: TAB REPLAY / SPACE CHANGE WHO PLAYS EACH ROLE',7)
            p.text(12,390,'ROLE KEYS STAY FIXED / RELEASE ALL KEYS AFTER TRANSITIONS',6)
            u=self.ui
            if u.title or u.paused or u.restart:
                p.rect(48,90,505,164,1);p.rectb(48,90,505,164,10)
                if u.title:
                    overlay=['START / ENTER (THEN RELEASE ALL KEYS)',
                        '1 / 2 / 3: GARDEN '+g.id, 'B: RIPPLES '+('OFF' if u.variant else 'ON'),
                        'ARROWS M N: PHOTOGRAPHER / WASD F: BIRD',
                        'TWO CHANGES + EXIT OR PHOTO / 72 TICKS']
                elif u.restart:
                    overlay=['RESTART REQUEST: WORLD IS STOPPED',
                        'Z: BIRD AGREES / ENTER: PHOTOGRAPHER AGREES',
                        'READY '+','.join(sorted(u.ready)), 'R: CANCEL / RELEASE ALL KEYS TO CONTINUE']
                else:overlay=['PAUSED: WORLD IS STOPPED','P / H: RESUME, THEN RELEASE ALL KEYS']
                for i,line in enumerate(overlay):p.text(63,108+i*25,line,10 if i==0 else 7)
            if u.swapped:p.text(336,280,'PLAYERS CHANGE ROLES; SAME KEYS',11)
        else:
            p.text(12,358,'HOLD ROLE KEYS, TAP SPACE, THEN RELEASE SPACE FOR NEXT TICK',7)
            p.text(12,374,'R: RESET LOADED STATE / ESC OR Q: QUIT',7)
            p.text(12,390,'SAVE CODE -> QUIT -> RERUN. R DOES NOT RELOAD THE FILE.',6)
        p.text(12,414,self.note or 'SYNTHETIC QA IS NOT A HUMAN TWO-PLAYER PLAYTEST.',6)

def checkpoint(app):
    if STAGE == 5:
        app.control({'RETURN'});app.control(set())
        for i in range(8):app.control({'UP','W','M'} if i == 0 else {'UP','W'})
    else:
        keys={'UP','W','SPACE'}
        if STAGE >= 2:keys.add('M')
        app.control(keys);app.control(set())

FIXTURES = ('conflict','photo_due','photo_escape','grass','grass_miss','ripples',
            'exit_priority','bird_win','timeout','replay_hidden','restart','role_swap')

def fixture(app, name):
    if STAGE != 5:raise ValueError('boundary fixtures require stage 5')
    u=app.ui;u.title=False;g=u.garden
    if name == 'conflict':
        for _ in range(8):app.control({'M','N','UP','DOWN','W'})
    elif name in ('photo_due','photo_escape'):
        u.state=replace(new(g),f=(0,0),h=(1,0))
        u.tick(Input(shoot=True))
        if name=='photo_escape':u.tick(Input(hdir=2))
    elif name in ('grass','grass_miss','replay_hidden'):
        u.state=replace(new(g),f=(2,0),h=(3,0),trail=((6,2),(6,1),(5,1),(4,1),(3,1),(3,0)))
        if name=='grass_miss':u.tick(Input(shoot=True));u.tick(Input())
        if name=='replay_hidden':u.state=replace(u.state,t=71);u.tick(Input());app.control({'TAB'})
    elif name=='ripples':
        u.state=replace(new(g),h=g.props[0]);u.tick(Input(alter=True,hdir=2))
    elif name=='exit_priority':
        u.state=replace(new(g),f=(6,3),h=(6,3),facing=2,changed=(0,1));u.tick(Input(shoot=True));u.tick(Input(hdir=2))
    elif name=='bird_win':
        u.state=replace(new(g),h=(6,3),changed=(0,1));u.tick(Input(hdir=2))
    elif name=='timeout':u.state=replace(new(g),t=71);u.tick(Input())
    elif name=='restart':app.control({'R'});app.control(set());app.control({'Z'})
    elif name=='role_swap':u.state=replace(new(g),result='H');app.control({'SPACE'});app.control(set())
    app.note='SYNTHETIC BOUNDARY FIXTURE: '+name.upper()

def exercise():
    reserve_photo = True
    g=GARDENS[0];s=replace(new(g),t=70,f=(6,3),h=(6,3),facing=2,changed=(0,1))
    s=step(g,s,Input(shoot=reserve_photo));s=step(g,s,Input(hdir=2))
    return {'tick':s.t,'result':s.result,'photo':bool(s.photo and s.photo[3])}

def output_path(parser, raw, extension):
    if raw is None:return None
    requested=Path(raw).expanduser()
    if requested.is_symlink():parser.error('output is a symlink; refusing')
    path=requested.resolve()
    if path.suffix != extension:parser.error('output extension must be '+extension)
    if not path.parent.is_dir():parser.error('output parent must already exist')
    if path.exists() or path.is_symlink():parser.error('output exists; refusing overwrite')
    return path

def main(args):
    # args の出力先は起動時 cwd 基準の絶対パス。Pyxel import/init より先に確定済み。
    import pyxel as p
    p.init(600,432,title='087 Hide photo lesson '+str(STAGE),fps=30,
           display_scale=1,headless=args.headless_smoke,quit_key=p.KEY_NONE)
    app=App()
    if args.demo_checkpoint:checkpoint(app)
    if args.fixture:fixture(app,args.fixture)
    names=('UP','RIGHT','DOWN','LEFT','W','D','S','A','M','N','F','SPACE',
           'TAB','R','P','H','Z','RETURN','1','2','3','B','F12','ESCAPE','Q')
    keys={key:getattr(p,'KEY_'+key) for key in names}
    def finish():
        app.draw(p)
        if args.screenshot:
            # save の上書き仕様へ依存しない。別名へ保存し、link の排他作成で確定する。
            import tempfile,os
            fd,temp=tempfile.mkstemp(suffix='.png',dir=args.screenshot.parent)
            os.close(fd)
            try:
                p.screen.save(temp,1)
                os.link(temp,args.screenshot)
            finally:Path(temp).unlink(missing_ok=True)
        if args.state_json:
            report={'stage':STAGE,'fixture':args.fixture,'demo':args.demo_checkpoint,
                    'label':'Actual Pyxel synthetic rendering; not human input',
                    'state':asdict(app.current()),'public':app.projection()}
            with args.state_json.open('x',encoding='utf-8') as handle:
                json.dump(report,handle,ensure_ascii=False,indent=2)
        p.quit()
    def update():
        if args.headless_smoke:
            if p.frame_count>=args.frames:finish()
            return
        down={key for key,value in keys.items() if p.btn(value)}
        if 'Q' in down or STAGE != 5 and 'ESCAPE' in down:finish();return
        app.control(down)
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--fixture',choices=FIXTURES)
    parser.add_argument('--frames',type=int,default=3)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--exercise',action='store_true')
    args=parser.parse_args()
    if args.exercise:
        if args.headless_smoke or args.demo_checkpoint or args.fixture or args.screenshot or args.state_json or args.frames != 3:
            parser.error('--exercise cannot be combined with rendering options')
        print(json.dumps(exercise(),ensure_ascii=True));raise SystemExit(0)
    if (args.demo_checkpoint or args.fixture or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    if args.fixture and (STAGE != 5 or args.demo_checkpoint):parser.error('fixture requires stage 5 without demo')
    if not 1<=args.frames<=300:parser.error('frames must be 1..300')
    args.screenshot=output_path(parser,args.screenshot,'.png')
    args.state_json=output_path(parser,args.state_json,'.json')
    main(args)
STEP 05 / 05

30fpsの入力を、毎秒4刻へまとめる

入力の予約、全部のキーが離されるのを待つ仕組み、2人の確認と担当の交代。

最後の段階では、元のゲームの Controller を使います。表紙で 1/2/3 から庭 A/B/C を選び、Enter を押してキーを全部離すと始まります。30fps の毎フレームで clock に4を足し、30以上なら30を引いて1刻進めます。最初は8フレーム、次は7フレームと交互になり、30フレームで4刻です。ルール上の1刻は、平均0.25秒です。パソコンや描画の遅れまで保証する時計ではありません。01〜04 の Space での1刻送りはなくなり、何も押さなくても閉園に向かって進みます。

矢印キーと WASD は、刻が来たフレームで押されているかで方向を読みます。短く押して、刻が来る前に離すと、動きません。一方、M/N/F は、fresh(今回新しく押されたキー)を pending(予約の入れ物)にためておき、次の刻で1回処理して消します。短い撮影ボタンの押下を、フレームの間の待ちで取りこぼしにくくするためです。押し続けても、撮影を連続ではしません。M と N を別々のフレームで押しても、同じ刻の pending に入れば、ぶつかったことになります。方向のぶつかりは、役ごとに別々に -1 です。

P または H で一時停止します。再開、開始、庭の選択、表紙への移動、再試合などでは、barrier が押されているキーを blocked に入れ、pending と clock を消して、キーが全部離されるのを待ちます。1つだけ離しても再開しません。切り替えのキーと方向キーが混ざって、画面が開いた直後に思いがけず動いてしまうのを防ぐためです。R は再試合の相談を開き、ことり担当の Z と、写真家担当の Enter の両方がそろうまで止まります。片方だけが同意しても、最初には戻りません。もう一度 R を押すと取り消しです。

結果が出たあと、Tab で足あとを表示し、Space で担当を交代します(キーの割り当ては役ごとに決まっています)。写真家はいつも矢印/M/N、ことりはいつも WASD/F です。swapped は担当の交代を知らせる表示で、キーを自動で入れかえる印ではありません。実際に席を移る必要もありません。Esc で表紙、Q でウィンドウを閉じます。元のゲームの F12 での画面の保存は、教材の画面では省き、はっきり指定した動作確認(QA)用のコマンドで画像を出します。

05 は、元のゲームのルールの定義すべてと Controller、庭 A/B/C を持っています。画面は600×432の英数字(ASCII)の簡単な表示に置きかえ、日本語の文字、元のゲームの記念写真の小窓、長い自動デモは省いています。表紙の B は、波紋の表示だけを切り替え、景色の変更や勝ち負けは同じです。組み立てた入力と、実際の Pyxel の描画は検査していますが、2人が実際のキーボードで同時に押すこと、Windows/macOS、ブラウザー、教材としてわかりやすいかは、まだ確かめていません。

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

05_input_clock.py / 注目する部分
        self.pending |= fresh & {'M', 'N', 'F'}
        self.clock += 4

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_input_clock.py
macOS / Linux
.venv/bin/python 05_input_clock.py
画面で見ることEnter → キーを全部離す → 最初の刻は8回の描画フレームのあと。そのあとは平均で1秒に4刻です。--exercise は frames_to_first_tick 8、tick 1 と出ます。R → 離す → Z だけでは再開せず、離してから Enter を押すと TICK 0 に戻ります。
  • フレームと、ルール上の刻を区別する
  • 押したキーの予約と、今押している方向を分ける
  • 2人の確認と、全部のキーが離されるのを待つことを守る
  • 担当を交代しても、役ごとのキーは変わらない
つまずきやすいところ毎フレーム step すると、1秒に30刻進んでしまいます。pending を毎フレーム消すと、短く押した M を取りこぼします。また、swapped を見て、矢印キーと WASD を勝手に入れかえると、元のゲームと違う操作になってしまいます。
少し変えてみよう

python 05_input_clock.py --exercise を実行してみましょう。Controller.feed の中の self.clock += 4 を self.clock += 5 に1か所変え、保存 → Q で閉じる → 実行し直すと、frames_to_first_tick 6 になります。1秒30フレームなら、1秒に5刻に相当する変更で、移動・撮影・閉園の実際の時間も速くなります。ルールの72刻を変えたわけではありません。確認したら4に戻します。

05_input_clock.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(547行)
05_input_clock.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。説明は LESSON.md。Python 3.11+ / Pyxel 2.9.9。"""
"""Pure deterministic simultaneous photography toy. No renderer or filesystem."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '087-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))

@dataclass(frozen=True)
class Garden:
    id: str
    title: str
    f: tuple
    h: tuple
    exit: tuple
    props: tuple
    walls: tuple
    grass: tuple
GARDENS = (Garden('A', '二つの植込み', (0, 2), (6, 2), (6, 4), ((1, 0), (3, 4), (5, 0)), ((2, 1), (2, 2), (4, 2), (4, 3)), ((3, 0), (4, 0), (5, 1), (3, 3), (3, 4))), Garden('B', '丸い池のまわり', (3, 0), (3, 4), (0, 4), ((0, 0), (6, 0), (6, 4)), ((2, 2), (3, 2), (4, 2)), ((0, 1), (0, 2), (1, 2), (5, 2), (6, 2), (6, 3))), Garden('C', '舞台袖', (0, 0), (6, 4), (6, 0), ((1, 3), (3, 0), (5, 3)), ((2, 0), (2, 1), (4, 3), (4, 4)), ((0, 3), (1, 3), (1, 4), (4, 1), (5, 1), (6, 1))))

@dataclass(frozen=True)
class Input:
    fdir: int = -1
    hdir: int = -1
    shoot: bool = False
    rotate: bool = False
    alter: bool = False

@dataclass(frozen=True)
class State:
    t: int = 0
    f: tuple = (0, 2)
    h: tuple = (6, 2)
    facing: int = 1
    cd: tuple = (0, 0)
    exposure: tuple = ()
    due: int = -1
    photo_wait: int = 0
    changed: tuple = ()
    ripples: tuple = ()
    result: str = ''
    events: tuple = ()
    photo: tuple = ()
    trail: tuple = ()
    shots: int = 0

def new(g):
    return State(f=g.f, h=g.h, trail=(g.h,))

def walk(g, p, d):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    return q if 0 <= q[0] < 7 and 0 <= q[1] < 5 and (q not in g.walls) else p

def mask(g, p, d):
    cells = [p]
    for _ in range(3):
        q = walk(g, cells[-1], d)
        if q == cells[-1]:
            break
        cells.append(q)
    return tuple(cells)

def step(g, s, a):
    if s.result:
        return s
    tick = s.t + 1
    events = []
    f = s.f
    h = s.h
    face = s.facing
    cd = [max(0, n - 1) for n in s.cd]
    changed = s.changed
    ripple = tuple(((i, n - 1) for i, n in s.ripples if n > 1))
    exp = s.exposure
    due = s.due
    wait = max(0, s.photo_wait - 1)
    photo = s.photo
    shots = s.shots
    result = ''
    if s.due < 0 and (not (a.shoot or a.rotate)) and (a.fdir in range(4)):
        face = a.fdir
        if s.cd[0] == 0:
            f = walk(g, s.f, a.fdir)
            if f != s.f:
                cd[0] = 1
            else:
                events.append('写真家: 通れない')
    if not a.alter and a.hdir in range(4) and (s.cd[1] == 0):
        h = walk(g, s.h, a.hdir)
        if h != s.h:
            cd[1] = 1
            events.append('ことり: 足音')
        else:
            events.append('ことり: 通れない')
    if a.alter:
        if h in g.props:
            i = g.props.index(h)
            if i not in changed:
                changed = tuple(sorted(changed + (i,)))
                events.append(('花が旗に', '雲が月に', '池が紙吹雪に')[i])
                ripple = tuple(((j, n) for j, n in ripple if j != i)) + ((i, 2),)
            else:
                events.append('同じ景色は一度だけ')
        else:
            events.append('仕掛けの上で F')
    if s.due < 0:
        if a.shoot and a.rotate:
            events.append('撮影と回転は一つずつ')
        elif a.rotate:
            face = (face + 1) % 4
        elif a.shoot:
            if s.photo_wait == 0:
                exp = mask(g, f, face)
                due = tick + 1
                events.append('白枠予告: 次の刻に撮影')
            else:
                events.append('写真を送っています')
    if s.due == tick:
        shots += 1
        hit = h in s.exposure and h not in g.grass
        photo = (tick, s.exposure, h if hit else None, hit)
        events.append('写った!' if hit else '空の写真も一枚の景色')
        result = 'F' if hit else ''
        exp = ()
        due = -1
        wait = 3
    if not result and len(changed) >= 2 and (h == g.exit):
        result = 'H'
        events.append('ふたつの景色を置いて、おかえり!')
    if not result and tick >= 72:
        result = 'F'
        events.append('閉園。写真家の守りきり')
    return State(tick, f, h, face, tuple(cd), exp, due, wait, changed, ripple, result, tuple(events), photo, s.trail + (h,), shots)

def public(g, s, ripples=True, replay=False):
    return {'t': s.t, 'f': s.f, 'h': s.h if s.h not in g.grass or (replay and s.result) else None, 'hidden': s.h in g.grass, 'facing': s.facing, 'mask': s.exposure, 'photo_wait': s.photo_wait, 'changed': s.changed, 'ripples': s.ripples if ripples else (), 'events': s.events, 'result': s.result, 'photo': s.photo, 'trail': s.trail if replay and s.result else ()}

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()

KEYS = {'UP', 'RIGHT', 'DOWN', 'LEFT', 'W', 'D', 'S', 'A', 'F', 'M', 'N', 'P', 'H', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'TAB', 'F12', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.variant = False
        self.title = True
        self.paused = False
        self.help = False
        self.restart = False
        self.ready = set()
        self.previous = set()
        self.blocked = set()
        self.release_gate = False
        self.pending = set()
        self.clock = 0
        self.replay = False
        self.swapped = False
        self.history = []
        self.state = new(self.garden)

    @property
    def garden(self):
        return GARDENS[self.level]

    def reset(self):
        self.state = new(self.garden)
        self.history = []
        self.clock = 0
        self.pending.clear()
        self.replay = False
        self.restart = False
        self.paused = False
        self.ready = set()

    def barrier(self, down):
        self.blocked |= down
        self.release_gate = True
        self.pending.clear()
        self.clock = 0

    def tick(self, a):
        old = self.state
        self.state = step(self.garden, old, a)
        if old != self.state:
            self.history.append((asdict(a), digest(self.state)))
        return 'tick'

    def feed(self, down):
        down = set(down) & KEYS
        self.blocked &= down
        fresh = down - self.previous - self.blocked
        self.previous = down
        active = down - self.blocked
        if self.release_gate:
            if not down:
                self.release_gate = False
            return None
        if 'F12' in fresh:
            return 'screenshot'
        if 'ESCAPE' in fresh:
            self.title = True
            self.barrier(down)
            return 'menu'
        if self.title:
            for k in ('1', '2', '3'):
                if k in fresh:
                    self.level = int(k) - 1
                    self.reset()
                    self.barrier(down)
                    return 'garden'
            if 'B' in fresh:
                self.variant = not self.variant
                self.barrier(down)
                return 'compare'
            if 'RETURN' in fresh:
                self.title = False
                self.reset()
                self.barrier(down)
                return 'start'
            return None
        if 'R' in fresh:
            self.restart = not self.restart
            self.ready = set()
            self.barrier(down)
            return 'restart_request'
        if self.restart:
            if 'Z' in fresh:
                self.ready.add('H')
            if 'RETURN' in fresh:
                self.ready.add('F')
            if len(self.ready) == 2:
                self.reset()
                self.barrier(down)
                return 'restart_confirmed'
            return None
        if 'P' in fresh or 'H' in fresh:
            self.paused = not self.paused
            self.barrier(down)
            return 'pause'
        if self.paused:
            return None
        if self.state.result:
            if 'TAB' in fresh:
                self.replay = not self.replay
                self.barrier(down)
                return 'replay'
            if 'SPACE' in fresh:
                self.swapped = not self.swapped
                self.reset()
                self.barrier(down)
                return 'role_swap'
            return None
        self.pending |= fresh & {'M', 'N', 'F'}
        self.clock += 4
        if self.clock < 30:
            return None
        self.clock -= 30

        def direction(keys):
            values = [i for i, k in enumerate(keys) if k in active]
            return values[0] if len(values) == 1 else -1
        a = Input(direction(('UP', 'RIGHT', 'DOWN', 'LEFT')), direction(('W', 'D', 'S', 'A')), 'M' in self.pending, 'N' in self.pending, 'F' in self.pending)
        self.pending.clear()
        return self.tick(a)

# この境界から下が教材の表示・操作。modelと画面を別々にテストできる。
STAGE = 5
STAGE_TITLE = 'FRAME INPUT QUEUE'

def direction(keys, down):
    values = [i for i, key in enumerate(keys) if key in down]
    return values[0] if len(values) == 1 else -1

class App:
    def __init__(self):
        self.note = ''
        self.previous = set()
        self.replay = False
        if STAGE == 5:
            self.ui = Controller()
        else:
            self.state = new(GARDENS[0])

    @property
    def garden(self):
        return self.ui.garden if STAGE == 5 else GARDENS[0]

    def current(self):
        return self.ui.state if STAGE == 5 else self.state

    def control(self, down):
        if STAGE == 5:
            event = self.ui.feed(down)
            if event == 'screenshot':
                self.note = 'F12 EXPORT OMITTED; USE DOCUMENTED QA OPTIONS'
            return event
        fresh = set(down) - self.previous
        self.previous = set(down)
        if 'R' in fresh:
            self.state, self.replay = new(self.garden), False
            return 'reset'
        if STAGE >= 4 and 'TAB' in fresh and self.state.result:
            self.replay = not self.replay
        if 'SPACE' not in fresh:
            return None
        a = Input(direction(('UP','RIGHT','DOWN','LEFT'), down),
                  direction(('W','D','S','A'), down),
                  STAGE >= 2 and 'M' in down, STAGE >= 2 and 'N' in down,
                  STAGE >= 3 and 'F' in down)
        self.state = step(self.garden, self.state, a)
        return 'tick'

    def projection(self):
        replay = self.ui.replay if STAGE == 5 else self.replay
        ripples = not self.ui.variant if STAGE == 5 else True
        return public(self.garden, self.current(), ripples, replay)

    def draw(self, p):
        s, g, v = self.current(), self.garden, self.projection()
        p.cls(1)
        p.text(12,12,'087 HIDE PHOTO / STAGE '+str(STAGE)+' / '+STAGE_TITLE,10)
        p.text(12,29,'TICK '+str(s.t)+' / '+('CLOSE AT 72' if STAGE >= 4 else 'NO CLOSING RULE YET'),7)
        p.text(360,29,'GARDEN '+g.id+' / '+('REALTIME 4HZ' if STAGE == 5 else 'SPACE = ONE TICK'),11)
        ox, oy, size = 12, 52, 44
        for y in range(5):
            for x in range(7):
                pos = (x,y); px, py = ox+x*size, oy+y*size
                color = 5 if pos in g.walls else 3 if STAGE >= 3 and pos in g.grass else 4
                p.rect(px,py,size-2,size-2,color)
                if pos in g.walls:
                    for dy in (10,21,32):p.line(px+4,py+dy,px+37,py+dy,6)
                if STAGE >= 3 and pos in g.grass:
                    for dx in (9,21,33):p.line(px+dx,py+32,px+dx-4,py+15,11)
                if STAGE >= 3 and pos in g.props:
                    index = g.props.index(pos)
                    p.circ(px+21,py+22,9,10 if index in v['changed'] else 14)
                    p.text(px+4,py+4,str(index+1),7)
                    if index in v['changed']:p.text(px+17,py+19,'+',1)
                if STAGE >= 4 and pos == g.exit:
                    p.rectb(px+8,py+9,26,28,10);p.text(px+12,py+31,'EXIT',10)
        for pos in v['trail']:
            p.circ(ox+pos[0]*size+21,oy+pos[1]*size+21,2,14)
        for index, ticks in v['ripples']:
            x,y=g.props[index];p.circb(ox+x*size+21,oy+y*size+21,19,10)
        for x,y in v['mask']:
            p.rectb(ox+x*size+2,oy+y*size+2,size-6,size-6,7)
        x,y=v['f'];cx,cy=ox+x*size+21,oy+y*size+23
        p.rect(cx-10,cy-8,20,16,12);p.circ(cx,cy,5,7)
        dx,dy=DIR[v['facing']];p.line(cx,cy,cx+dx*18,cy+dy*18,10)
        if v['h'] is not None:
            x,y=v['h'];cx,cy=ox+x*size+22,oy+y*size+23
            p.circ(cx,cy,8,10);p.tri(cx+7,cy-2,cx+13,cy+2,cx+7,cy+4,9);p.pset(cx+3,cy-3,0)
        lines=[
            'PHOTOGRAPHER: ARROWS',
            'BIRD: W A S D',
            'CONTACT / SWAP ALLOWED',
            'MOVE COOLDOWN '+str(s.cd),
            '',
            'M: SHOOT / N: ROTATE' if STAGE >= 2 else 'ONLY MOVEMENT IN STAGE 1',
            'NEXT-TICK EXPOSURE' if STAGE >= 2 else 'NO SHUTTER / NO GRASS',
            'PHOTO WAIT '+str(v['photo_wait']),
            'DUE '+str(getattr(s,'due',-1)),
            'F: CHANGE SCENERY' if STAGE >= 3 else 'NO SCENERY ACTION YET',
            'CHANGED '+str(len(v['changed']))+' / 2',
            'BIRD HIDDEN IN GRASS' if v['hidden'] else 'BIRD VISIBLE',
            'REPLAY: POST-RESULT ONLY' if STAGE >= 4 else 'TRAIL NOT DISPLAYED',
            'RESULT '+(v['result'] or 'PLAYING'),
        ]
        for i,line in enumerate(lines):p.text(336,55+i*14,line,10 if i in (0,1,13) else 7)
        events={'写真家: 通れない':'PHOTOGRAPHER: BLOCKED','ことり: 足音':'BIRD: FOOTSTEP',
                'ことり: 通れない':'BIRD: BLOCKED','花が旗に':'FLOWER BECOMES FLAG',
                '雲が月に':'CLOUD BECOMES MOON','池が紙吹雪に':'POND BECOMES CONFETTI',
                '同じ景色は一度だけ':'SCENERY CHANGES ONCE','仕掛けの上で F':'USE F ON A PROP',
                '撮影と回転は一つずつ':'M + N CONFLICT: BIRD STILL ACTS',
                '白枠予告: 次の刻に撮影':'WHITE FRAME: EXPOSURE NEXT TICK',
                '写真を送っています':'SHUTTER IS COOLING DOWN','写った!':'PHOTO HIT: PHOTOGRAPHER WINS',
                '空の写真も一枚の景色':'PHOTO MISS','ふたつの景色を置いて、おかえり!':'TWO CHANGES + EXIT: BIRD WINS',
                '閉園。写真家の守りきり':'72 TICKS: PHOTOGRAPHER WINS'}
        for i,event in enumerate(v['events'][-3:]):p.text(12,289+i*15,events.get(event,'EVENT'),10)
        if v['result']:p.text(336,265,'WINNER: '+('BIRD' if v['result']=='H' else 'PHOTOGRAPHER'),10)
        if v['trail']:p.text(12,335,'POST-RESULT REPLAY / PINK DOTS = TRAIL',14)
        if STAGE == 5:
            p.text(12,358,'P/H: PAUSE / R: RESTART REQUEST / ESC: MENU / Q: QUIT',7)
            p.text(12,374,'RESULT: TAB REPLAY / SPACE CHANGE WHO PLAYS EACH ROLE',7)
            p.text(12,390,'ROLE KEYS STAY FIXED / RELEASE ALL KEYS AFTER TRANSITIONS',6)
            u=self.ui
            if u.title or u.paused or u.restart:
                p.rect(48,90,505,164,1);p.rectb(48,90,505,164,10)
                if u.title:
                    overlay=['START / ENTER (THEN RELEASE ALL KEYS)',
                        '1 / 2 / 3: GARDEN '+g.id, 'B: RIPPLES '+('OFF' if u.variant else 'ON'),
                        'ARROWS M N: PHOTOGRAPHER / WASD F: BIRD',
                        'TWO CHANGES + EXIT OR PHOTO / 72 TICKS']
                elif u.restart:
                    overlay=['RESTART REQUEST: WORLD IS STOPPED',
                        'Z: BIRD AGREES / ENTER: PHOTOGRAPHER AGREES',
                        'READY '+','.join(sorted(u.ready)), 'R: CANCEL / RELEASE ALL KEYS TO CONTINUE']
                else:overlay=['PAUSED: WORLD IS STOPPED','P / H: RESUME, THEN RELEASE ALL KEYS']
                for i,line in enumerate(overlay):p.text(63,108+i*25,line,10 if i==0 else 7)
            if u.swapped:p.text(336,280,'PLAYERS CHANGE ROLES; SAME KEYS',11)
        else:
            p.text(12,358,'HOLD ROLE KEYS, TAP SPACE, THEN RELEASE SPACE FOR NEXT TICK',7)
            p.text(12,374,'R: RESET LOADED STATE / ESC OR Q: QUIT',7)
            p.text(12,390,'SAVE CODE -> QUIT -> RERUN. R DOES NOT RELOAD THE FILE.',6)
        p.text(12,414,self.note or 'SYNTHETIC QA IS NOT A HUMAN TWO-PLAYER PLAYTEST.',6)

def checkpoint(app):
    if STAGE == 5:
        app.control({'RETURN'});app.control(set())
        for i in range(8):app.control({'UP','W','M'} if i == 0 else {'UP','W'})
    else:
        keys={'UP','W','SPACE'}
        if STAGE >= 2:keys.add('M')
        app.control(keys);app.control(set())

FIXTURES = ('conflict','photo_due','photo_escape','grass','grass_miss','ripples',
            'exit_priority','bird_win','timeout','replay_hidden','restart','role_swap')

def fixture(app, name):
    if STAGE != 5:raise ValueError('boundary fixtures require stage 5')
    u=app.ui;u.title=False;g=u.garden
    if name == 'conflict':
        for _ in range(8):app.control({'M','N','UP','DOWN','W'})
    elif name in ('photo_due','photo_escape'):
        u.state=replace(new(g),f=(0,0),h=(1,0))
        u.tick(Input(shoot=True))
        if name=='photo_escape':u.tick(Input(hdir=2))
    elif name in ('grass','grass_miss','replay_hidden'):
        u.state=replace(new(g),f=(2,0),h=(3,0),trail=((6,2),(6,1),(5,1),(4,1),(3,1),(3,0)))
        if name=='grass_miss':u.tick(Input(shoot=True));u.tick(Input())
        if name=='replay_hidden':u.state=replace(u.state,t=71);u.tick(Input());app.control({'TAB'})
    elif name=='ripples':
        u.state=replace(new(g),h=g.props[0]);u.tick(Input(alter=True,hdir=2))
    elif name=='exit_priority':
        u.state=replace(new(g),f=(6,3),h=(6,3),facing=2,changed=(0,1));u.tick(Input(shoot=True));u.tick(Input(hdir=2))
    elif name=='bird_win':
        u.state=replace(new(g),h=(6,3),changed=(0,1));u.tick(Input(hdir=2))
    elif name=='timeout':u.state=replace(new(g),t=71);u.tick(Input())
    elif name=='restart':app.control({'R'});app.control(set());app.control({'Z'})
    elif name=='role_swap':u.state=replace(new(g),result='H');app.control({'SPACE'});app.control(set())
    app.note='SYNTHETIC BOUNDARY FIXTURE: '+name.upper()

def exercise():
    u=Controller();u.title=False;frames=0
    while u.state.t==0:
        u.feed(set());frames+=1
    return {'frames_to_first_tick':frames,'tick':u.state.t}

def output_path(parser, raw, extension):
    if raw is None:return None
    requested=Path(raw).expanduser()
    if requested.is_symlink():parser.error('output is a symlink; refusing')
    path=requested.resolve()
    if path.suffix != extension:parser.error('output extension must be '+extension)
    if not path.parent.is_dir():parser.error('output parent must already exist')
    if path.exists() or path.is_symlink():parser.error('output exists; refusing overwrite')
    return path

def main(args):
    # args の出力先は起動時 cwd 基準の絶対パス。Pyxel import/init より先に確定済み。
    import pyxel as p
    p.init(600,432,title='087 Hide photo lesson '+str(STAGE),fps=30,
           display_scale=1,headless=args.headless_smoke,quit_key=p.KEY_NONE)
    app=App()
    if args.demo_checkpoint:checkpoint(app)
    if args.fixture:fixture(app,args.fixture)
    names=('UP','RIGHT','DOWN','LEFT','W','D','S','A','M','N','F','SPACE',
           'TAB','R','P','H','Z','RETURN','1','2','3','B','F12','ESCAPE','Q')
    keys={key:getattr(p,'KEY_'+key) for key in names}
    def finish():
        app.draw(p)
        if args.screenshot:
            # save の上書き仕様へ依存しない。別名へ保存し、link の排他作成で確定する。
            import tempfile,os
            fd,temp=tempfile.mkstemp(suffix='.png',dir=args.screenshot.parent)
            os.close(fd)
            try:
                p.screen.save(temp,1)
                os.link(temp,args.screenshot)
            finally:Path(temp).unlink(missing_ok=True)
        if args.state_json:
            report={'stage':STAGE,'fixture':args.fixture,'demo':args.demo_checkpoint,
                    'label':'Actual Pyxel synthetic rendering; not human input',
                    'state':asdict(app.current()),'public':app.projection()}
            with args.state_json.open('x',encoding='utf-8') as handle:
                json.dump(report,handle,ensure_ascii=False,indent=2)
        p.quit()
    def update():
        if args.headless_smoke:
            if p.frame_count>=args.frames:finish()
            return
        down={key for key,value in keys.items() if p.btn(value)}
        if 'Q' in down or STAGE != 5 and 'ESCAPE' in down:finish();return
        app.control(down)
    p.run(update,lambda:app.draw(p))

if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--fixture',choices=FIXTURES)
    parser.add_argument('--frames',type=int,default=3)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--exercise',action='store_true')
    args=parser.parse_args()
    if args.exercise:
        if args.headless_smoke or args.demo_checkpoint or args.fixture or args.screenshot or args.state_json or args.frames != 3:
            parser.error('--exercise cannot be combined with rendering options')
        print(json.dumps(exercise(),ensure_ascii=True));raise SystemExit(0)
    if (args.demo_checkpoint or args.fixture or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    if args.fixture and (STAGE != 5 or args.demo_checkpoint):parser.error('fixture requires stage 5 without demo')
    if not 1<=args.frames<=300:parser.error('frames must be 1..300')
    args.screenshot=output_path(parser,args.screenshot,'.png')
    args.state_json=output_path(parser,args.state_json,'.json')
    main(args)
AFTER STEP 05

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

ルールのまとめ:2人は同じ前の状態から、2刻に1歩動きます。重なるのも入れかわるのも自由。予約した次の刻に、ことりが動いたあとの位置で撮影。草の中では写らない。景色を2つ変えて出口に着けばことりの勝ち、72刻で写真家の勝ち。同じ刻なら、写真 → 出口 → 閉園の順。

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

比べる点5ステップの教材元の完成版
段階01 移動、02 予約しての撮影、03 草と景色、04 出口と閉園、05 元のゲームの入力の時計と、2人の確認。最初からルール全部。
画面と時間600×432の英数字(ASCII)の簡単な図。01〜04 は庭 A だけで Space での1刻送り、R ですぐ最初に戻す。05 は30fpsで平均1秒に4刻、庭 A/B/C を選べる。900×660の日本語と専用の文字。最初からリアルタイム、2人の確認で再試合。
ルールと入力04/05 のルールの定義すべてと、05 の Controller と定数を、元のゲームとプログラムの構造(AST)・限られた入力の列で比べています。担当を交代します(キーの割り当ては役ごとに決まっています)。model.py と controller.py が別々のファイル。Undo なし。swapped は担当の交代の表示で、キーの割り当ては変えません。
省略と検査段階ごとに1つのファイルで、assets は不要。F12 での保存・記念写真の小窓・長いデモは省略。はっきり指定したコマンドで、試験用の状態の PNG 画像と JSON を出します。日本語の文字、記念写真、F12、デモの記録があります。

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

  1. model.py の step:s.exposure と、動いたあとの h
  2. model.py の public:草の扱いと、結果のあとのリプレイの条件
  3. controller.py の feed:pending と clock
  4. controller.py の barrier / restart:全部のキーが離されるのを待つ仕組みと、2人の同意
  5. app.py:役ごとに決まったキーと、swapped の表示
  6. strategies.py の escape_plan / catch_plan:限られた入力の例
自分のゲームに変えるなら

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

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