探索・アドベンチャーLESSON 96架空の三夜グリッドパズル原作093

小さな怪盗の三夜の作り方

今夜の足あとが、次の夜の警備を変える。
選んだ行動を確定することと、警備を見る「新しい刻(時間の区切り)」を分け、品物を取る・入口に帰る・3つの夜で警備が変わる、を順番に作ります。状態全部を戻せる Undo と、保存・読みこみもつなぎます。

完成版の遊び方前の夜の足あとで変わる警備を読み、待つ道や別の窓を選んで、3つの小物を持ち帰ります。

小さな怪盗の三夜の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Aの第2夜、品を持って帰路を選ぶ場面です。自動操作GIFから画素を変えず取り出した一コマで、人の試遊ではありません。教材は移動から始め、03で一夜、04以降で三夜を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:矢印方向/Space待機/Z品・帰還を選ぶ、Enter一刻、X戻す、R同夜/N三夜再開

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

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

うまく表示されないとき

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

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

前の夜の行動を、次の夜のルールに渡す。

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

1. 1列に並べた位置の番号・書き換えない状態・キーを離すまで待つ仕組み
2. くり返しの周期・見せている予告・40刻とやり直し
3. 持っている品物・帰ってくる条件・履歴の写し
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数と、整数の割り算・余り / 関数・if・辞書・dataclass/replace
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印/Space/Z:行動を選択しEnterで確定。各キー間は全離し / X:03以降でUndo。確認中は取消 / R:確認して今夜再試行。コード再読込ではない / N/M/H/P:04以降で三夜リセット・表紙・説明 / S/L:05の幕間/結果で保存読込 / Esc:確認して終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

壁のある館で、選択と移動を分ける

1列に並べた位置の番号・書き換えない状態・キーを離すまで待つ仕組み。

最初は、7列×5行の館だけを作ります。位置 p の列は p%7(7で割った余り)、行は p//7(7で割った商)です。上・真ん中・下の横の道と、両はしの縦の道が FLOOR(歩ける床)です。S=14 から右へ動くと15ですが、そこから上の8は壁です。p に -7 を足すだけでは、壁も画面のはしも通り抜けてしまいます。動く先の x,y が範囲の中か、FLOOR かを両方調べ、無効なら同じ s を返します。

State は pos と t の2つの項目だけです。今は、警備も品物も夜もありません。矢印キーは selected(選んでいる行動)を変えるだけで、Enter のときに step に渡して、新しい State を代入します。draw は状態を読むだけです。枠の中の PREVIEW は、同じ step を仮に呼んで見せるだけで、本当の状態は置きかえません。t は有効な移動の回数で、現実の秒数やフレームの数ではありません。

Controller の previous と gate は、押しっぱなしと同時押しを区別するための、入力の足場です。history という配列は、あとの 03 で使うために共通の入力の部分に残していますが、この段階では Undo のキーは働きません。画面の描画、コマンド(CLI)、確認の画面も、それぞれの段階が1つのファイルで動くための足場です。ゲームの完成したルールは、まだありません。

キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。

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

01_walk.py / 注目する部分
    x, y = s.pos % 7 + delta[0], s.pos // 7 + delta[1]
    q = y * 7 + x
    if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
        return s
    return replace(s, pos=q, t=s.t + 1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ること起動した直後は POS=14、TICK=0 です。右 → 離す だけでは動きません。Enter → 離す で 15・1 になります。上 → 離す → Enter → 離す は壁なので、15・1 のままです。
  • キーで選ぶことと、確定することを分ける
  • 壁や画面のはしでは、時刻も変わらない
  • pos/t だけで、ひとりで動く
つまずきやすいところ行の最後の6から右への移動を許すと、次の行に回りこんでしまいます。動く先の x が7なら、先に断ります。描いている間に self.s を更新しないでください。
少し変えてみよう

START = 14 を START = 0 に1か所変えてみましょう。保存 → 終了 → 実行し直します。左 → 離す → Enter → 離す は 0・0 のままです。右 → 離す → Enter → 離す で 1・1 になります。python exercises/01_start.py も、書き換えたコピーで同じ操作を実行します。確認したら14に戻します。

01_walk.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(326行)
01_walk.py / 全文
# -*- coding: utf-8 -*-
# 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.

STAGE = 1

from dataclasses import dataclass, replace, asdict
START = 14
FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4))
ACTIONS = ('up', 'down', 'left', 'right')
@dataclass(frozen=True)
class State:
    pos: int = START
    t: int = 0

def new():
    return State()

def step(s, action):
    delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action)
    if delta is None:
        return s
    x, y = s.pos % 7 + delta[0], s.pos // 7 + delta[1]
    q = y * 7 + x
    if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
        return s
    return replace(s, pos=q, t=s.t + 1)

KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'}
class Controller:
    def __init__(self, path=None):
        self.s = new()
        self.selected = 'right'
        self.previous = set()
        self.gate = False
        self.dialog = ''
        self.history = []
        self.log = []
        self.notice = ''
        self.title = False
        self.help = False

    def apply(self, action):
        if action == 'undo':
            if not self.history:
                return 'ignored:undo'
            self.s = self.history.pop()
            return 'undo'
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            return 'ignored:' + action
        self.history.append(old)
        return action

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(down) > 1:
            return 'reject:chord'
        key = next(iter(fresh))
        if self.dialog:
            if key in ('X', 'ESCAPE'):
                self.dialog = ''
                return 'cancel'
            if key == 'RETURN':
                dialog, self.dialog = self.dialog, ''
                if dialog == 'quit':
                    return 'quit'
                if STAGE == 1:
                    self.s = new()
                    return 'reset'
                return self.apply('retry')
            return 'ignored:dialog'
        if key == 'ESCAPE':
            self.dialog = 'quit'
            return 'confirm:quit'
        if key == 'R' and getattr(self.s, 'phase', 'play') == 'play':
            self.dialog = 'retry'
            return 'confirm:retry'
        if key == 'X' and STAGE >= 3:
            return self.apply('undo')
        action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key)
        if action in ACTIONS:
            self.selected = action
            return 'select:' + action
        if key == 'RETURN':
            return self.apply(self.selected)
        return 'ignored:key'

# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())  # Every distinct key press has a full release.
    return event

def commit(ui, action):
    if action == 'next':
        return tap(ui, 'RETURN')
    key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
    tap(ui, key)
    return tap(ui, 'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        tap(ui, 'RETURN')
    if scenario == 'checkpoint':
        commit(ui, 'right')
        return
    if scenario == 'wall':
        commit(ui, 'right'); commit(ui, 'up')
    elif scenario in ('caught', 'preview') and STAGE >= 2:
        if STAGE >= 4:
            ui.s = new('A')
        ui.s = replace(ui.s, guards=(0, 1, 0))
        commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
        tap(ui, 'RIGHT')
        if scenario == 'caught':
            tap(ui, 'RETURN')
    elif scenario == 'timeout' and STAGE >= 2:
        for _ in range(40):
            commit(ui, 'wait')
    elif scenario == 'confirm':
        commit(ui, 'right'); tap(ui, 'R')
    elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
        nights = 3 if scenario == 'success' and STAGE >= 4 else 1
        for night in range(nights):
            if night:
                commit(ui, 'next')
            # A deterministic public model route, waiting before occupied gates.
            for direction in ('right', 'left'):
                for _ in range(6):
                    q = ui.s.pos + (1 if direction == 'right' else -1)
                    while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
                        commit(ui, 'wait')
                    commit(ui, direction)
                if direction == 'left' and scenario == 'return40':
                    while ui.s.t < 39:
                        commit(ui, 'wait')
                commit(ui, 'use')
        if scenario in ('next', 'undo_next') and STAGE >= 4:
            commit(ui, 'next')
            if scenario == 'undo_next':
                tap(ui, 'X')
        if scenario in ('saved','loaded') and STAGE == 5:
            tap(ui, 'S' if scenario == 'saved' else 'L')
    else:
        raise ValueError('scenario not available at this stage')

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
                        (['caught','preview','timeout'] if STAGE >= 2 else []) +
                        (['returned','return40'] if STAGE >= 3 else []) +
                        (['next','undo_next','success'] if STAGE >= 4 else []) +
                        (['saved','loaded'] if STAGE == 5 else []))
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--state-json', type=Path)
    parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
        parser.error('diagnostic flags require --headless-smoke')
    if args.demo_checkpoint and args.scenario:
        parser.error('choose --demo-checkpoint or --scenario')
    # Resolve before pyxel.init, which can alter working-directory behavior.
    for name, suffix in (('screenshot','.png'),('state_json','.json')):
        path = getattr(args, name)
        if path is None:
            continue
        path = path.resolve()
        if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
            parser.error(name + ': require a new file with correct suffix in an existing directory')
        setattr(args, name, path)
    args.save_file = args.save_file.resolve()
    if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
        parser.error('save-file: require a .json file in an existing directory')
    if args.save_file in (args.screenshot, args.state_json):
        parser.error('save-file and diagnostic outputs must differ')
    if args.scenario == 'saved' and args.save_file.exists():
        parser.error('saved scenario must use a new save file')
    return args

class App:
    def __init__(self, args):
        self.args = args
        self.ui = Controller(args.save_file)
        self.frame = 0
        if args.headless_smoke:
            demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
                   fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
        self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
        pyxel.run(self.update, self.draw)

    def update(self):
        self.frame += 1
        if self.args.headless_smoke:
            if self.frame >= 3:
                pyxel.quit()
            return
        event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
        if event == 'quit':
            pyxel.quit()

    def draw(self):
        u, s = self.ui, self.ui.s
        pyxel.cls(1)
        pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
        pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + '   TICK: ' + str(s.t) + '   POS: ' + str(s.pos), 7)
        pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + '  PHASE: ' + getattr(s,'phase','play').upper(), 11)
        for p in range(35):
            x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
            pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
            if p in FLOOR:
                pyxel.rectb(x, y, 39, 39, 13)
            if STAGE >= 2 and p in GATES:
                g = s.guards[GATES.index(p)]
                pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
                pyxel.text(x+5,y+28,'G'+str(g),7)
            if p == START:
                pyxel.text(x+3,y+29,'S',11)
            if STAGE >= 3 and p == TREASURE:
                pyxel.text(x+3,y+29,'T',10)
                if not s.holding and s.phase == 'play':
                    pyxel.circ(x+25,y+16,5,10)
        x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
        pyxel.circ(x,y,6,12)
        if getattr(s,'holding',False):
            pyxel.circ(x+9,y,3,10)
        pyxel.rect(315,48,233,210,2)
        if STAGE >= 2:
            pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
            for i,g in enumerate(s.guards):
                future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
                pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
            pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
        if STAGE >= 3:
            pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
            pyxel.text(325,179,'HOME: '+str(len(s.items))+'  USED BITS: '+str(s.used),11)
        phase = getattr(s,'phase','play')
        if phase == 'play':
            preview = step(s,u.selected)
            msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
            pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
            pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
        else:
            pyxel.text(325,208,'RESULT: '+phase.upper(),10)
            if phase == 'intermission':
                next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
                pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
            else:
                pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
        pyxel.text(12,272,'Arrows: select direction   ENTER: commit   Release ALL keys between presses',7)
        if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait   R: confirm retry (current night only)',6)
        if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return   X: Undo one committed decision',6)
        if STAGE >= 4: pyxel.text(12,320,'N: new  M: menu  H/P: help  Title: 1/2/3 case, B variant',6)
        if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
        pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.notice:
            notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
            pyxel.text(12,375,notice,10)
        if u.title or u.help:
            pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
            pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
            pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
            pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
            pyxel.text(50,155,'Case: '+s.case+'   Variant: '+s.variant,11)
            pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
            pyxel.text(50,205,'ENTER: start  H/P: close help. Release keys first.',10)
        if u.dialog:
            pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
            pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
            pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
            pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
        if self.args.headless_smoke and self.frame == 2:
            if self.args.screenshot:
                pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
                      'dialog':u.dialog,'history':[asdict(v) for v in u.history],
                      'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
                with self.args.state_json.open('x',encoding='utf-8') as handle:
                    json.dump(data,handle,ensure_ascii=False,indent=2)

if __name__ == '__main__':
    args = parse_args()
    import pyxel
    App(args)
STEP 02 / 05

新しい刻の門番を読む

くり返しの周期・見せている予告・40刻とやり直し。

02 で、GATES=(3,17,31)(門の位置)と guards(警備)を加えます。教材の最初の警備は、真ん中だけが1です。これは時間の流れを観察しやすい練習用の配置で、元のゲームの状況 A の最初の値ではありません。警備0はいつもいない、1は偶数の刻、2は3で割った余りが0か1の刻に、門にいます。行動したあとの t=s.t+1 で、動いた先の p と重なるかを調べます。となりのマスや、視線の判定はありません。

Space は「待つ」を選びます。Enter を押すまで、時間は進みません。右の PUBLIC GUARDS は、次の3刻の、見せている警備の周期、PREVIEW は、今選んでいる行動の1刻ぶんだけの予告です。予告の CAUGHT / RESET(見つかる・やり直し)は、まだ失敗した結果ではありません。実際に Enter で確定したときだけ、fails(失敗の回数)が増え、入口・t0 に戻り、その挑戦で使った刻が total に入ります。門から出たなら、古い場所との重なりは調べません。

有効な行動が40刻になっても、入口に戻ります。失敗しても guards は変わりません。R → 離す で確認の画面が開き、Enter → 離す で今の練習を入口に戻します。X → 離す なら取り消しです。R でのやり直しでは、fails/total は増えません。まだ品物と夜がないので、成功で終わることはありません。

キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。

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

02_guard_clock.py / 注目する部分
    t = s.t + 1
    if p in GATES and occupied(s.guards[GATES.index(p)], t):
        return retry(s, True, t)
    if t >= 40:
        return retry(s, True, t)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_guard_clock.py
macOS / Linux
.venv/bin/python 02_guard_clock.py
画面で見ること右 → Enter、右 → Enter で p16、t2 です。Space → Enter で p16、t3。右を選ぶだけで PREVIEW が CAUGHT / RESET になります。Enter を押すと p14、t0、FAILS=1、TOTAL=4 です。キーは、毎回全部離します。
  • 1刻先の予告と、実際の結果を区別する
  • 無効な移動では、刻は進まない
  • やり直しても、警備は変わらない
つまずきやすいところs.t の時点の警備を調べると、1刻ずれてしまいます。待つ場合も新しい刻で判定するので、門の上で待つのが安全とは限りません。
少し変えてみよう

State の guards: tuple = (0, 1, 0) を (0, 2, 0) に変え、保存 → 終了 → 実行し直してみましょう。右 → 離す → Enter → 離す を3回押します。3刻目に真ん中の門で見つかり、FAILS=1、TOTAL=3、TICK=0 です。元の警備1なら、3刻目は通れます。python exercises/02_cycle.py で両方の版を実行できます。最後に (0, 1, 0) に戻します。

02_guard_clock.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(349行)
02_guard_clock.py / 全文
# -*- coding: utf-8 -*-
# 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.

STAGE = 2

from dataclasses import dataclass, replace, asdict
START = 14
GATES = (3, 17, 31)
FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4))
ACTIONS = ('up', 'down', 'left', 'right', 'wait')
@dataclass(frozen=True)
class State:
    pos: int = START
    t: int = 0
    guards: tuple = (0, 1, 0)
    fails: int = 0
    total: int = 0

def new():
    return State()

def occupied(level, t):
    return level == 1 and t % 2 == 0 or level == 2 and t % 3 in (0, 1)

def forecast(s):
    return tuple(tuple(occupied(g, s.t + k) for g in s.guards) for k in range(1, 4))

def retry(s, failure=False, spent=0):
    return replace(s, pos=START, t=0, fails=s.fails + int(failure), total=s.total + spent)

def step(s, action):
    if action == 'retry':
        return retry(s)
    if action not in ACTIONS:
        return s
    p = s.pos
    delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action)
    if delta:
        x, y = p % 7 + delta[0], p // 7 + delta[1]
        q = y * 7 + x
        if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
            return s
        p = q
    t = s.t + 1
    if p in GATES and occupied(s.guards[GATES.index(p)], t):
        return retry(s, True, t)
    if t >= 40:
        return retry(s, True, t)
    return replace(s, pos=p, t=t)

KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'}
class Controller:
    def __init__(self, path=None):
        self.s = new()
        self.selected = 'right'
        self.previous = set()
        self.gate = False
        self.dialog = ''
        self.history = []
        self.log = []
        self.notice = ''
        self.title = False
        self.help = False

    def apply(self, action):
        if action == 'undo':
            if not self.history:
                return 'ignored:undo'
            self.s = self.history.pop()
            return 'undo'
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            return 'ignored:' + action
        self.history.append(old)
        return action

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(down) > 1:
            return 'reject:chord'
        key = next(iter(fresh))
        if self.dialog:
            if key in ('X', 'ESCAPE'):
                self.dialog = ''
                return 'cancel'
            if key == 'RETURN':
                dialog, self.dialog = self.dialog, ''
                if dialog == 'quit':
                    return 'quit'
                if STAGE == 1:
                    self.s = new()
                    return 'reset'
                return self.apply('retry')
            return 'ignored:dialog'
        if key == 'ESCAPE':
            self.dialog = 'quit'
            return 'confirm:quit'
        if key == 'R' and getattr(self.s, 'phase', 'play') == 'play':
            self.dialog = 'retry'
            return 'confirm:retry'
        if key == 'X' and STAGE >= 3:
            return self.apply('undo')
        action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key)
        if action in ACTIONS:
            self.selected = action
            return 'select:' + action
        if key == 'RETURN':
            return self.apply(self.selected)
        return 'ignored:key'

# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())  # Every distinct key press has a full release.
    return event

def commit(ui, action):
    if action == 'next':
        return tap(ui, 'RETURN')
    key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
    tap(ui, key)
    return tap(ui, 'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        tap(ui, 'RETURN')
    if scenario == 'checkpoint':
        commit(ui, 'right')
        return
    if scenario == 'wall':
        commit(ui, 'right'); commit(ui, 'up')
    elif scenario in ('caught', 'preview') and STAGE >= 2:
        if STAGE >= 4:
            ui.s = new('A')
        ui.s = replace(ui.s, guards=(0, 1, 0))
        commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
        tap(ui, 'RIGHT')
        if scenario == 'caught':
            tap(ui, 'RETURN')
    elif scenario == 'timeout' and STAGE >= 2:
        for _ in range(40):
            commit(ui, 'wait')
    elif scenario == 'confirm':
        commit(ui, 'right'); tap(ui, 'R')
    elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
        nights = 3 if scenario == 'success' and STAGE >= 4 else 1
        for night in range(nights):
            if night:
                commit(ui, 'next')
            # A deterministic public model route, waiting before occupied gates.
            for direction in ('right', 'left'):
                for _ in range(6):
                    q = ui.s.pos + (1 if direction == 'right' else -1)
                    while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
                        commit(ui, 'wait')
                    commit(ui, direction)
                if direction == 'left' and scenario == 'return40':
                    while ui.s.t < 39:
                        commit(ui, 'wait')
                commit(ui, 'use')
        if scenario in ('next', 'undo_next') and STAGE >= 4:
            commit(ui, 'next')
            if scenario == 'undo_next':
                tap(ui, 'X')
        if scenario in ('saved','loaded') and STAGE == 5:
            tap(ui, 'S' if scenario == 'saved' else 'L')
    else:
        raise ValueError('scenario not available at this stage')

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
                        (['caught','preview','timeout'] if STAGE >= 2 else []) +
                        (['returned','return40'] if STAGE >= 3 else []) +
                        (['next','undo_next','success'] if STAGE >= 4 else []) +
                        (['saved','loaded'] if STAGE == 5 else []))
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--state-json', type=Path)
    parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
        parser.error('diagnostic flags require --headless-smoke')
    if args.demo_checkpoint and args.scenario:
        parser.error('choose --demo-checkpoint or --scenario')
    # Resolve before pyxel.init, which can alter working-directory behavior.
    for name, suffix in (('screenshot','.png'),('state_json','.json')):
        path = getattr(args, name)
        if path is None:
            continue
        path = path.resolve()
        if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
            parser.error(name + ': require a new file with correct suffix in an existing directory')
        setattr(args, name, path)
    args.save_file = args.save_file.resolve()
    if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
        parser.error('save-file: require a .json file in an existing directory')
    if args.save_file in (args.screenshot, args.state_json):
        parser.error('save-file and diagnostic outputs must differ')
    if args.scenario == 'saved' and args.save_file.exists():
        parser.error('saved scenario must use a new save file')
    return args

class App:
    def __init__(self, args):
        self.args = args
        self.ui = Controller(args.save_file)
        self.frame = 0
        if args.headless_smoke:
            demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
                   fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
        self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
        pyxel.run(self.update, self.draw)

    def update(self):
        self.frame += 1
        if self.args.headless_smoke:
            if self.frame >= 3:
                pyxel.quit()
            return
        event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
        if event == 'quit':
            pyxel.quit()

    def draw(self):
        u, s = self.ui, self.ui.s
        pyxel.cls(1)
        pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
        pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + '   TICK: ' + str(s.t) + '   POS: ' + str(s.pos), 7)
        pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + '  PHASE: ' + getattr(s,'phase','play').upper(), 11)
        for p in range(35):
            x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
            pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
            if p in FLOOR:
                pyxel.rectb(x, y, 39, 39, 13)
            if STAGE >= 2 and p in GATES:
                g = s.guards[GATES.index(p)]
                pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
                pyxel.text(x+5,y+28,'G'+str(g),7)
            if p == START:
                pyxel.text(x+3,y+29,'S',11)
            if STAGE >= 3 and p == TREASURE:
                pyxel.text(x+3,y+29,'T',10)
                if not s.holding and s.phase == 'play':
                    pyxel.circ(x+25,y+16,5,10)
        x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
        pyxel.circ(x,y,6,12)
        if getattr(s,'holding',False):
            pyxel.circ(x+9,y,3,10)
        pyxel.rect(315,48,233,210,2)
        if STAGE >= 2:
            pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
            for i,g in enumerate(s.guards):
                future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
                pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
            pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
        if STAGE >= 3:
            pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
            pyxel.text(325,179,'HOME: '+str(len(s.items))+'  USED BITS: '+str(s.used),11)
        phase = getattr(s,'phase','play')
        if phase == 'play':
            preview = step(s,u.selected)
            msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
            pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
            pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
        else:
            pyxel.text(325,208,'RESULT: '+phase.upper(),10)
            if phase == 'intermission':
                next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
                pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
            else:
                pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
        pyxel.text(12,272,'Arrows: select direction   ENTER: commit   Release ALL keys between presses',7)
        if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait   R: confirm retry (current night only)',6)
        if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return   X: Undo one committed decision',6)
        if STAGE >= 4: pyxel.text(12,320,'N: new  M: menu  H/P: help  Title: 1/2/3 case, B variant',6)
        if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
        pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.notice:
            notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
            pyxel.text(12,375,notice,10)
        if u.title or u.help:
            pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
            pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
            pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
            pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
            pyxel.text(50,155,'Case: '+s.case+'   Variant: '+s.variant,11)
            pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
            pyxel.text(50,205,'ENTER: start  H/P: close help. Release keys first.',10)
        if u.dialog:
            pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
            pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
            pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
            pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
        if self.args.headless_smoke and self.frame == 2:
            if self.args.screenshot:
                pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
                      'dialog':u.dialog,'history':[asdict(v) for v in u.history],
                      'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
                with self.args.state_json.open('x',encoding='utf-8') as handle:
                    json.dump(data,handle,ensure_ascii=False,indent=2)

if __name__ == '__main__':
    args = parse_args()
    import pyxel
    App(args)
STEP 03 / 05

包んで帰る一夜を完成させる

持っている品物・帰ってくる条件・履歴の写し。

03 は、1つの夜だけの完成したゲームです。警備を元のゲームの状況 A と同じ0に戻し、右はしの T=20 で Z を選んで Enter で品物を包みます。T に着いただけでは持てません。holding=True(持っている)で入口 S に帰り、もう一度 Z → Enter で帰ると、HOME=1・DONE になります。品物がない入口や、移動の途中での use(使う)は無効で、刻も履歴も増えません。

used は、通った門の集まりを、3つのビットで記録します。真ん中を往復しても2のままです。trace には、有効な1刻ごとの位置を足していき、最初の位置も含むので、長さは t+1 です。この2つの項目は、04 で足あとを次の夜に渡すための準備ですが、03 でも画面の USED BITS と状態の JSON で確かめられます。

40刻目でも入口に帰れれば、DONE を先に決めるので成功です。失敗や R でのやり直しでは、持ち物・足あと・trace を、今の夜の最初の値に戻します。03 には、前の夜はまだありません。X は、変わる前の State を history から1つ戻します。位置だけを逆算せず、時刻・持ち物・結果・失敗・total も一緒に戻ります。DONE からも、帰りを確定する前に戻せます。

キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。

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

03_one_night.py / 注目する部分
    if action == 'use':
        if p == TREASURE:
            holding = True
        else:
            holding, items, phase, total = False, (0,), 'done', total + t
    if t >= 40 and phase == 'play':
        return retry(s, True, t)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_one_night.py
macOS / Linux
.venv/bin/python 03_one_night.py
画面で見ること右 → Enter を6回、Z → Enter で p20、t7、HOLDING=1 です。左 → Enter を6回、Z → Enter で p14、t14、HOME=1、DONE です。X → 離す で t13、HOLDING=1、HOME=0、PLAY に戻ります。キーは必ず離します。
  • 品物を取ることと、持ち帰ることは、別の1刻
  • 40刻目の成功は、成功のまま
  • 結果が出たあとの Undo も、状態全部を戻す
つまずきやすいところt>=40 の判定を帰りの処理より前に置くと、40刻目の正しい帰りを失敗にしてしまいます。X で t だけを戻すと、持ち物とつじつまが合わなくなります。
少し変えてみよう

State の guards: tuple = (0, 0, 0) を (0, 1, 0) に変えてみましょう。保存 → 終了 → 実行し直します。右 → Enter を6回、Z → Enter、左 → Enter を2回押します。p18、t9 で Space → Enter を1回、左 → Enter を4回、Z → Enter と押すと、t15 で DONE です。X で t14 の帰る直前に戻ります。キーは毎回全部離します。python exercises/03_return.py で実行できます。元に戻します。

03_one_night.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(368行)
03_one_night.py / 全文
# -*- coding: utf-8 -*-
# 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.

STAGE = 3

from dataclasses import dataclass, replace, asdict
START = 14
TREASURE = 20
GATES = (3, 17, 31)
FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4))
ACTIONS = ('up', 'down', 'left', 'right', 'wait', 'use')
@dataclass(frozen=True)
class State:
    pos: int = START
    t: int = 0
    guards: tuple = (0, 0, 0)
    fails: int = 0
    total: int = 0
    holding: bool = False
    items: tuple = ()
    phase: str = 'play'
    used: int = 0
    trace: tuple = (START,)

def new():
    return State()

def occupied(level, t):
    return level == 1 and t % 2 == 0 or level == 2 and t % 3 in (0, 1)

def forecast(s):
    return tuple(tuple(occupied(g, s.t + k) for g in s.guards) for k in range(1, 4))

def retry(s, failure=False, spent=0):
    if s.phase != 'play':
        return s
    return replace(s, pos=START, t=0, holding=False, used=0, trace=(START,),
                   fails=s.fails + int(failure), total=s.total + spent)

def step(s, action):
    if action == 'retry':
        return retry(s)
    if s.phase != 'play' or action not in ACTIONS:
        return s
    if action == 'use' and not (s.pos == TREASURE and not s.holding or s.pos == START and s.holding):
        return s
    p = s.pos
    delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action)
    if delta:
        x, y = p % 7 + delta[0], p // 7 + delta[1]
        q = y * 7 + x
        if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR):
            return s
        p = q
    t = s.t + 1
    if p in GATES and occupied(s.guards[GATES.index(p)], t):
        return retry(s, True, t)
    used = s.used | (1 << GATES.index(p) if p in GATES else 0)
    holding, items, phase, total = s.holding, s.items, s.phase, s.total
    if action == 'use':
        if p == TREASURE:
            holding = True
        else:
            holding, items, phase, total = False, (0,), 'done', total + t
    if t >= 40 and phase == 'play':
        return retry(s, True, t)
    return replace(s, pos=p, t=t, holding=holding, used=used, items=items,
                   phase=phase, total=total, trace=s.trace + (p,))

KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'}
class Controller:
    def __init__(self, path=None):
        self.s = new()
        self.selected = 'right'
        self.previous = set()
        self.gate = False
        self.dialog = ''
        self.history = []
        self.log = []
        self.notice = ''
        self.title = False
        self.help = False

    def apply(self, action):
        if action == 'undo':
            if not self.history:
                return 'ignored:undo'
            self.s = self.history.pop()
            return 'undo'
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            return 'ignored:' + action
        self.history.append(old)
        return action

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(down) > 1:
            return 'reject:chord'
        key = next(iter(fresh))
        if self.dialog:
            if key in ('X', 'ESCAPE'):
                self.dialog = ''
                return 'cancel'
            if key == 'RETURN':
                dialog, self.dialog = self.dialog, ''
                if dialog == 'quit':
                    return 'quit'
                if STAGE == 1:
                    self.s = new()
                    return 'reset'
                return self.apply('retry')
            return 'ignored:dialog'
        if key == 'ESCAPE':
            self.dialog = 'quit'
            return 'confirm:quit'
        if key == 'R' and getattr(self.s, 'phase', 'play') == 'play':
            self.dialog = 'retry'
            return 'confirm:retry'
        if key == 'X' and STAGE >= 3:
            return self.apply('undo')
        action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key)
        if action in ACTIONS:
            self.selected = action
            return 'select:' + action
        if key == 'RETURN':
            return self.apply(self.selected)
        return 'ignored:key'

# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())  # Every distinct key press has a full release.
    return event

def commit(ui, action):
    if action == 'next':
        return tap(ui, 'RETURN')
    key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
    tap(ui, key)
    return tap(ui, 'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        tap(ui, 'RETURN')
    if scenario == 'checkpoint':
        commit(ui, 'right')
        return
    if scenario == 'wall':
        commit(ui, 'right'); commit(ui, 'up')
    elif scenario in ('caught', 'preview') and STAGE >= 2:
        if STAGE >= 4:
            ui.s = new('A')
        ui.s = replace(ui.s, guards=(0, 1, 0))
        commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
        tap(ui, 'RIGHT')
        if scenario == 'caught':
            tap(ui, 'RETURN')
    elif scenario == 'timeout' and STAGE >= 2:
        for _ in range(40):
            commit(ui, 'wait')
    elif scenario == 'confirm':
        commit(ui, 'right'); tap(ui, 'R')
    elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
        nights = 3 if scenario == 'success' and STAGE >= 4 else 1
        for night in range(nights):
            if night:
                commit(ui, 'next')
            # A deterministic public model route, waiting before occupied gates.
            for direction in ('right', 'left'):
                for _ in range(6):
                    q = ui.s.pos + (1 if direction == 'right' else -1)
                    while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
                        commit(ui, 'wait')
                    commit(ui, direction)
                if direction == 'left' and scenario == 'return40':
                    while ui.s.t < 39:
                        commit(ui, 'wait')
                commit(ui, 'use')
        if scenario in ('next', 'undo_next') and STAGE >= 4:
            commit(ui, 'next')
            if scenario == 'undo_next':
                tap(ui, 'X')
        if scenario in ('saved','loaded') and STAGE == 5:
            tap(ui, 'S' if scenario == 'saved' else 'L')
    else:
        raise ValueError('scenario not available at this stage')

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
                        (['caught','preview','timeout'] if STAGE >= 2 else []) +
                        (['returned','return40'] if STAGE >= 3 else []) +
                        (['next','undo_next','success'] if STAGE >= 4 else []) +
                        (['saved','loaded'] if STAGE == 5 else []))
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--state-json', type=Path)
    parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
        parser.error('diagnostic flags require --headless-smoke')
    if args.demo_checkpoint and args.scenario:
        parser.error('choose --demo-checkpoint or --scenario')
    # Resolve before pyxel.init, which can alter working-directory behavior.
    for name, suffix in (('screenshot','.png'),('state_json','.json')):
        path = getattr(args, name)
        if path is None:
            continue
        path = path.resolve()
        if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
            parser.error(name + ': require a new file with correct suffix in an existing directory')
        setattr(args, name, path)
    args.save_file = args.save_file.resolve()
    if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
        parser.error('save-file: require a .json file in an existing directory')
    if args.save_file in (args.screenshot, args.state_json):
        parser.error('save-file and diagnostic outputs must differ')
    if args.scenario == 'saved' and args.save_file.exists():
        parser.error('saved scenario must use a new save file')
    return args

class App:
    def __init__(self, args):
        self.args = args
        self.ui = Controller(args.save_file)
        self.frame = 0
        if args.headless_smoke:
            demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
                   fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
        self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
        pyxel.run(self.update, self.draw)

    def update(self):
        self.frame += 1
        if self.args.headless_smoke:
            if self.frame >= 3:
                pyxel.quit()
            return
        event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
        if event == 'quit':
            pyxel.quit()

    def draw(self):
        u, s = self.ui, self.ui.s
        pyxel.cls(1)
        pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
        pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + '   TICK: ' + str(s.t) + '   POS: ' + str(s.pos), 7)
        pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + '  PHASE: ' + getattr(s,'phase','play').upper(), 11)
        for p in range(35):
            x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
            pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
            if p in FLOOR:
                pyxel.rectb(x, y, 39, 39, 13)
            if STAGE >= 2 and p in GATES:
                g = s.guards[GATES.index(p)]
                pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
                pyxel.text(x+5,y+28,'G'+str(g),7)
            if p == START:
                pyxel.text(x+3,y+29,'S',11)
            if STAGE >= 3 and p == TREASURE:
                pyxel.text(x+3,y+29,'T',10)
                if not s.holding and s.phase == 'play':
                    pyxel.circ(x+25,y+16,5,10)
        x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
        pyxel.circ(x,y,6,12)
        if getattr(s,'holding',False):
            pyxel.circ(x+9,y,3,10)
        pyxel.rect(315,48,233,210,2)
        if STAGE >= 2:
            pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
            for i,g in enumerate(s.guards):
                future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
                pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
            pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
        if STAGE >= 3:
            pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
            pyxel.text(325,179,'HOME: '+str(len(s.items))+'  USED BITS: '+str(s.used),11)
        phase = getattr(s,'phase','play')
        if phase == 'play':
            preview = step(s,u.selected)
            msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
            pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
            pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
        else:
            pyxel.text(325,208,'RESULT: '+phase.upper(),10)
            if phase == 'intermission':
                next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
                pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
            else:
                pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
        pyxel.text(12,272,'Arrows: select direction   ENTER: commit   Release ALL keys between presses',7)
        if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait   R: confirm retry (current night only)',6)
        if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return   X: Undo one committed decision',6)
        if STAGE >= 4: pyxel.text(12,320,'N: new  M: menu  H/P: help  Title: 1/2/3 case, B variant',6)
        if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
        pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.notice:
            notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
            pyxel.text(12,375,notice,10)
        if u.title or u.help:
            pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
            pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
            pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
            pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
            pyxel.text(50,155,'Case: '+s.case+'   Variant: '+s.variant,11)
            pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
            pyxel.text(50,205,'ENTER: start  H/P: close help. Release keys first.',10)
        if u.dialog:
            pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
            pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
            pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
            pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
        if self.args.headless_smoke and self.frame == 2:
            if self.args.screenshot:
                pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
                      'dialog':u.dialog,'history':[asdict(v) for v in u.history],
                      'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
                with self.args.state_json.open('x',encoding='utf-8') as handle:
                    json.dump(data,handle,ensure_ascii=False,indent=2)

if __name__ == '__main__':
    args = parse_args()
    import pyxel
    App(args)
STEP 04 / 05

前夜の足あとを次の夜へ渡す

3つの夜の状態の移り変わり・警備の変化と比較の実験。

04 で、元のゲームの model.py の State、新しく始めるときの設定、移動、警備の変化(適応)を、そのまま取り入れます。表紙の 1/2/3 で状況 A/B/C を選び、B で adaptive(警備が変わる)と fixed(変わらない)を切り替えます。Enter → 離す で始まります。元のゲームの日本語のデータはコードの中に残し、画面は英数字(ASCII)です。03 の1夜での DONE は、1夜目と2夜目では intermission(夜と夜の間の幕間)に変わり、3夜目で done になります。

持ち帰った items は、次の夜にも残ります。幕間で Enter を確定した瞬間に night を増やし、guards を adapted に通します。使った門は +1、使わなかった門は -1 で、範囲は0〜2です。used は今夜の集まりなので、往復しても2回は増えません。previous_used に前の夜の集まりを記録してから、今夜の used を0にします。失敗・やり直し・最後の結果では、警備は変わりません。fixed では、移動、周期、40刻、品物の条件は同じで、警備の更新だけを止めます。

夜をまたぐことも1つの判断なので、X で幕間の状態全部に戻せます。R は今夜だけ、N は3つの夜全部の新しい開始で、どちらも確認と、キーを全部離すことが必要です。M は表紙に戻る確認、H/P は説明を開く・閉じるです。表紙や説明、確認の画面では、刻は進みません。S/L での保存はまだ入れず、05 で保存用の確認の関数と一緒に加えます。

キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。

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

04_three_nights.py / 注目する部分
def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_three_nights.py
macOS / Linux
.venv/bin/python 04_three_nights.py
画面で見ること状況 A で Enter で始めます。右 → Enter を6回、Z → Enter、左 → Enter を6回、Z → Enter で幕間です。NEXT GUARDS=(0,1,0) と出ます。Enter → 離す で2夜目、t0、HOME=1 です。X → 離す で1夜目の幕間、t14 に戻ります。
  • 品物は残し、今夜の足あとは消す
  • 警備が変わるのは、次の夜に進むとき(next)だけ
  • X で、夜の境目も戻せる
つまずきやすいところ成功して帰った直後に警備を書き換えると、幕間で今夜の警備がわからなくなります。次の夜に進む操作が、変わるときの境目です。最後の結果のあとには、次の夜の予告は出しません。
少し変えてみよう

adapted の中の min(2,g+1) を min(2,g+2) に変え、保存 → 終了 → 実行し直してみましょう。表紙で Enter → 離す。状況 A の真ん中の往復14刻(右 → Enter ×6、Z → Enter、左 → Enter ×6、Z → Enter)のあと、幕間で NEXT GUARDS=(0,2,0) になります。Enter → 離す で、同じ警備の2夜目です。X → 離す で、警備 (0,0,0) の1夜目の幕間に戻ります。python exercises/04_adaptation.py で実行できます。最後に g+1 に戻します。

04_three_nights.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(328行)
04_three_nights.py / 全文
# -*- coding: utf-8 -*-
# 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.

STAGE = 4

"""Three-night infiltration. Pure, immutable and deterministic; no private AI input."""
from dataclasses import dataclass,replace,asdict,fields
import hashlib,json
VERSION='093-1.0';GAME_ID='093';START=14;TREASURE=20;GATES=(3,17,31)
TITLES={'A':'静かな一夜目','B':'窓番のいる館','C':'足あとが多い館'}
INITIAL={'A':(0,0,0),'B':(1,0,1),'C':(2,1,0)}
ITEMS=('月のティーカップ','星のオルゴール','朝の小さな鏡')
ROUTES=('上の窓','中央の扉','下の窓');ACTIONS=('up','down','left','right','wait','use')
FLOOR=frozenset(p for p in range(35) if p%7 in (0,6) or p//7 in (0,2,4))
@dataclass(frozen=True)
class State:
 case:str='A';variant:str='adaptive';night:int=0;pos:int=14;holding:bool=False;t:int=0;guards:tuple=(0,0,0);used:int=0;items:tuple=();phase:str='play';fails:int=0;total:int=0;previous_used:int=0;trace:tuple=(14,);note:str='今夜はどの道から?'
def new(case='A',variant='adaptive'):
 if case not in INITIAL or variant not in ('adaptive','fixed'):raise ValueError('invalid setup')
 return State(case=case,variant=variant,guards=INITIAL[case])
def occupied(level,t):return level==1 and t%2==0 or level==2 and t%3 in (0,1)
def forecast(s):return tuple(tuple(occupied(g,s.t+k) for g in s.guards) for k in range(1,4))
def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards))
def retry(s,note='この夜だけ最初から。前夜までの品はそのまま。',failure=False,spent=0):
 if s.phase!='play':return s
 return replace(s,pos=START,holding=False,t=0,used=0,trace=(START,),fails=s.fails+int(failure),total=s.total+spent,note=note)
def step(s,a):
 if a=='next' and s.phase=='intermission':
  return replace(s,night=s.night+1,pos=START,holding=False,t=0,guards=adapted(s.guards,s.used) if s.variant=='adaptive' else s.guards,previous_used=s.used,used=0,phase='play',trace=(START,),note='昨日の足あとだけを覚えています。' if s.variant=='adaptive' else '比較版: 警備は初期値のまま。')
 if a=='retry':return retry(s)
 if s.phase!='play' or a not in ACTIONS:return s
 if a=='use' and not (s.pos==TREASURE and not s.holding or s.pos==START and s.holding):return s
 p=s.pos;d={'up':(0,-1),'down':(0,1),'left':(-1,0),'right':(1,0)}.get(a)
 if d:
  x,y=p%7+d[0],p//7+d[1];q=y*7+x
  if not(0<=x<7 and 0<=y<5 and q in FLOOR):return s
  p=q
 t=s.t+1
 if p in GATES and occupied(s.guards[GATES.index(p)],t):
  return retry(s,'見つかった! この夜の入口から、もう一度。',True,t)
 used=s.used|(1<<GATES.index(p) if p in GATES else 0);holding=s.holding;items=s.items;phase=s.phase;total=s.total
 note='待って、次の番人の位置を読む。' if a=='wait' else '足音をひとつ。'
 if a=='use':
  if p==TREASURE:holding=True;note=ITEMS[s.night]+'を包んだ。入口へ戻ろう。'
  else:
   holding=False;items=items+(s.night,);phase='done' if s.night==2 else 'intermission';total+=t;note='三品が部屋にそろいました。' if phase=='done' else ('帰還! 次の夜は、この足あとへ反応。' if s.variant=='adaptive' else '帰還! 次の夜も初期警備のまま。')
 if t>=40 and phase=='play':return retry(s,'40刻。今夜の入口から再試行できます。',True,t)
 return replace(s,pos=p,t=t,holding=holding,used=used,items=items,phase=phase,total=total,trace=s.trace+(p,),note=note)

KEYS={'SPACE','RETURN','Z','X','P','H','R','N','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','S','L','F12','ESCAPE'}
class Controller:
 def __init__(self,path='save_093.json'):
  self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.selected='right'
 def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.selected='right'
 def apply(self,a):
  if a=='undo':
   if not self.history:return 'ignored:undo'
   self.s=self.history.pop();self.log.append(a);return a
  old=self.s;self.s=step(old,a)
  if self.s==old:return 'ignored:'+a
  self.history.append(old);self.log.append(a);return a
 def feed(self,down):
  down=set(down)&KEYS;fresh=down-self.previous;self.previous=down
  if self.gate:
   if not down:self.gate=False
   return None
  if not fresh:return None
  self.gate=True
  if len(down)>1:return 'reject:chord'
  k=next(iter(fresh));self.notice=''
  if k=='F12':return 'screenshot'
  if self.dialog:
   if k in ('X','ESCAPE'):self.dialog='';return 'cancel'
   if k=='RETURN':
    d=self.dialog;self.dialog=''
    if d=='quit':return 'quit'
    if d=='new':self.reset();return 'reset:campaign'
    if d=='retry':return self.apply('retry')
    if d=='menu':self.title=True;return 'menu'
   return 'ignored:dialog'
  if k=='ESCAPE':self.dialog='quit';return 'confirm:quit'
  if k in ('P','H'):self.help=not self.help;return 'help'
  if self.help:return 'ignored:help'
  if self.title:
   if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
   if k=='B':self.s=new(self.s.case,'fixed' if self.s.variant=='adaptive' else 'adaptive');self.history=[];self.log=[];return 'variant'
   if k in ('SPACE','RETURN'):self.title=False;return 'start'
   return 'ignored:title'
  if k=='M':self.dialog='menu';return 'confirm:menu'
  if k=='N':self.dialog='new';return 'confirm:new'
  if k=='R' and self.s.phase=='play':self.dialog='retry';return 'confirm:retry'
  if k=='X':return self.apply('undo')
  if self.s.phase=='intermission' and k=='RETURN':return self.apply('next')
  if self.s.phase!='play':return 'ignored:result'
  a={'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(k)
  if a:self.selected=a;return 'select:'+a
  if k=='RETURN':return self.apply(self.selected)
  return 'ignored:key'

# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())  # Every distinct key press has a full release.
    return event

def commit(ui, action):
    if action == 'next':
        return tap(ui, 'RETURN')
    key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
    tap(ui, key)
    return tap(ui, 'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        tap(ui, 'RETURN')
    if scenario == 'checkpoint':
        commit(ui, 'right')
        return
    if scenario == 'wall':
        commit(ui, 'right'); commit(ui, 'up')
    elif scenario in ('caught', 'preview') and STAGE >= 2:
        if STAGE >= 4:
            ui.s = new('A')
        ui.s = replace(ui.s, guards=(0, 1, 0))
        commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
        tap(ui, 'RIGHT')
        if scenario == 'caught':
            tap(ui, 'RETURN')
    elif scenario == 'timeout' and STAGE >= 2:
        for _ in range(40):
            commit(ui, 'wait')
    elif scenario == 'confirm':
        commit(ui, 'right'); tap(ui, 'R')
    elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
        nights = 3 if scenario == 'success' and STAGE >= 4 else 1
        for night in range(nights):
            if night:
                commit(ui, 'next')
            # A deterministic public model route, waiting before occupied gates.
            for direction in ('right', 'left'):
                for _ in range(6):
                    q = ui.s.pos + (1 if direction == 'right' else -1)
                    while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
                        commit(ui, 'wait')
                    commit(ui, direction)
                if direction == 'left' and scenario == 'return40':
                    while ui.s.t < 39:
                        commit(ui, 'wait')
                commit(ui, 'use')
        if scenario in ('next', 'undo_next') and STAGE >= 4:
            commit(ui, 'next')
            if scenario == 'undo_next':
                tap(ui, 'X')
        if scenario in ('saved','loaded') and STAGE == 5:
            tap(ui, 'S' if scenario == 'saved' else 'L')
    else:
        raise ValueError('scenario not available at this stage')

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
                        (['caught','preview','timeout'] if STAGE >= 2 else []) +
                        (['returned','return40'] if STAGE >= 3 else []) +
                        (['next','undo_next','success'] if STAGE >= 4 else []) +
                        (['saved','loaded'] if STAGE == 5 else []))
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--state-json', type=Path)
    parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
        parser.error('diagnostic flags require --headless-smoke')
    if args.demo_checkpoint and args.scenario:
        parser.error('choose --demo-checkpoint or --scenario')
    # Resolve before pyxel.init, which can alter working-directory behavior.
    for name, suffix in (('screenshot','.png'),('state_json','.json')):
        path = getattr(args, name)
        if path is None:
            continue
        path = path.resolve()
        if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
            parser.error(name + ': require a new file with correct suffix in an existing directory')
        setattr(args, name, path)
    args.save_file = args.save_file.resolve()
    if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
        parser.error('save-file: require a .json file in an existing directory')
    if args.save_file in (args.screenshot, args.state_json):
        parser.error('save-file and diagnostic outputs must differ')
    if args.scenario == 'saved' and args.save_file.exists():
        parser.error('saved scenario must use a new save file')
    return args

class App:
    def __init__(self, args):
        self.args = args
        self.ui = Controller(args.save_file)
        self.frame = 0
        if args.headless_smoke:
            demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
                   fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
        self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
        pyxel.run(self.update, self.draw)

    def update(self):
        self.frame += 1
        if self.args.headless_smoke:
            if self.frame >= 3:
                pyxel.quit()
            return
        event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
        if event == 'quit':
            pyxel.quit()

    def draw(self):
        u, s = self.ui, self.ui.s
        pyxel.cls(1)
        pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
        pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + '   TICK: ' + str(s.t) + '   POS: ' + str(s.pos), 7)
        pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + '  PHASE: ' + getattr(s,'phase','play').upper(), 11)
        for p in range(35):
            x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
            pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
            if p in FLOOR:
                pyxel.rectb(x, y, 39, 39, 13)
            if STAGE >= 2 and p in GATES:
                g = s.guards[GATES.index(p)]
                pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
                pyxel.text(x+5,y+28,'G'+str(g),7)
            if p == START:
                pyxel.text(x+3,y+29,'S',11)
            if STAGE >= 3 and p == TREASURE:
                pyxel.text(x+3,y+29,'T',10)
                if not s.holding and s.phase == 'play':
                    pyxel.circ(x+25,y+16,5,10)
        x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
        pyxel.circ(x,y,6,12)
        if getattr(s,'holding',False):
            pyxel.circ(x+9,y,3,10)
        pyxel.rect(315,48,233,210,2)
        if STAGE >= 2:
            pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
            for i,g in enumerate(s.guards):
                future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
                pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
            pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
        if STAGE >= 3:
            pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
            pyxel.text(325,179,'HOME: '+str(len(s.items))+'  USED BITS: '+str(s.used),11)
        phase = getattr(s,'phase','play')
        if phase == 'play':
            preview = step(s,u.selected)
            msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
            pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
            pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
        else:
            pyxel.text(325,208,'RESULT: '+phase.upper(),10)
            if phase == 'intermission':
                next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
                pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
            else:
                pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
        pyxel.text(12,272,'Arrows: select direction   ENTER: commit   Release ALL keys between presses',7)
        if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait   R: confirm retry (current night only)',6)
        if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return   X: Undo one committed decision',6)
        if STAGE >= 4: pyxel.text(12,320,'N: new  M: menu  H/P: help  Title: 1/2/3 case, B variant',6)
        if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
        pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.notice:
            notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
            pyxel.text(12,375,notice,10)
        if u.title or u.help:
            pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
            pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
            pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
            pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
            pyxel.text(50,155,'Case: '+s.case+'   Variant: '+s.variant,11)
            pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
            pyxel.text(50,205,'ENTER: start  H/P: close help. Release keys first.',10)
        if u.dialog:
            pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
            pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
            pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
            pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
        if self.args.headless_smoke and self.frame == 2:
            if self.args.screenshot:
                pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
                      'dialog':u.dialog,'history':[asdict(v) for v in u.history],
                      'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
                with self.args.state_json.open('x',encoding='utf-8') as handle:
                    json.dump(data,handle,ensure_ascii=False,indent=2)

if __name__ == '__main__':
    args = parse_args()
    import pyxel
    App(args)
STEP 05 / 05

保存とUndoを三夜の記録につなぐ

UTF-8 での保存・確かめてから読みこむ・確定した入力の境目。

05 には、元のゲームの model.py の定義とデータ全部、Controller の定義、storage の save/load の定義が入っています。いくつものファイルの import だけを、1つのファイル用につなぎ直し、storage という小さな名前のまとまりが、同じ関数を見せています。辞書から State を作り直すときは、項目の名前、型、決まった値の種類、範囲、items の順番、trace の長さと最後を調べます。これは元のゲームの形式の確認で、すべての保存が正しい入力の列から作られたことを、暗号のように保証するものではありません。

S/L が使えるのは、幕間か、3つの夜の結果のときだけです。プレイ中は blocked:save-phase(保存できない場面)です。保存先は、起動した作業フォルダーの save_093.json で、--save-file ではっきり指定することもできます。UTF-8 の一時的なファイルに書き、flush/fsync のあとで置きかえます。保存は同じファイルを上書きしますが、確認用の画像や JSON は、すでにある出力への上書きを断ります。作品の ID・版、case/variant、状態全部、Undo の履歴、意味のある入力を保存します。L は、確認が成功してから、今の3つの値を同時に置きかえ、失敗したときはファイルの内容を使いません。

読みこんだ履歴でも、X は直前の判断に戻ります。次の夜へ進む(next)も、やり直し(retry)も、Undo の対象です。N で新しく始めても保存ファイルは残りますが、L を使うには、もう一度幕間か結果まで進めてください。表紙の L では読みこみません。S は Python のコードの保存ではありません。エディターでの保存と区別しましょう。

キーは1回押すごとに、全部離してください。Enter を押し続けても、次の1刻は進みません。コードを書き換えたら保存し、Esc → キーを全部離す → Enter で終了して、同じ python のコマンドで実行し直します。R はゲームの状態をやり直すだけで、コードを読み直すわけではありません。

元のゲームの日本語の文字、飾り、F12 での記録、起動時のコードの複製、出来事の記録は省いています。キーそのものは Controller に残っていますが、F12 の画面の保存の出来事を、教材の画面は保存しません。音はありません。元のゲームに進むときは、app.py の record/flush_events と text、storage.py の load を読みましょう。

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

05_save_undo.py / 注目する部分
  if k in ('S','L'):
   if self.s.phase not in ('intermission','done'):self.notice='保存と読込は夜と夜の間、または三夜の結果で。';return 'blocked:save-phase'
   try:
    if k=='S':storage.save(self.path,self.s,self.history,self.log);self.notice='この三夜を保存しました。';return 'save'
    self.s,self.history,self.log=storage.load(self.path);self.notice='保存した三夜を読み込みました。';return 'load'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_save_undo.py
macOS / Linux
.venv/bin/python 05_save_undo.py
画面で見ること状況 A を始めて S → 離す と押しても、保存できません。真ん中を往復して幕間に進み、S → 離す で保存します。Enter → 離す で2夜目、X → 離す で幕間、L → 離す で、保存したときの状態と履歴に戻ります。X で、帰る前の t13 に戻ります。
  • 保存は、幕間か結果のときだけ
  • UTF-8 と、確かめてから元に戻す仕組み
  • 読みこんだあとの Undo も、状態全部を戻す
つまずきやすいところjson.load が成功しただけで、状態が正しいと判断しないでください。encoding を省くと、パソコンの環境によって結果が変わります。L の途中で self.s だけを先に変えると、失敗したときに履歴と状態が食い違います。
少し変えてみよう

Controller.__init__ の最後の self.path=path;self.selected='right' だけを、self.path=path;self.selected='wait' に変えてみましょう。保存 → 終了 → 実行し直します。表紙で Enter → 離す では t0。さらに Enter → 離す で、入口のまま t1 です。X → 離す で t0。右 → 離す → Enter → 離す で p15、t1 です。python exercises/05_initial_selection.py が、書き換えと、同じ開始・確定・Undo を実行します。最後に right に戻します。

05_save_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(377行)
05_save_undo.py / 全文
# -*- coding: utf-8 -*-
# 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.

STAGE = 5

"""Three-night infiltration. Pure, immutable and deterministic; no private AI input."""
from dataclasses import dataclass,replace,asdict,fields
import hashlib,json
VERSION='093-1.0';GAME_ID='093';START=14;TREASURE=20;GATES=(3,17,31)
TITLES={'A':'静かな一夜目','B':'窓番のいる館','C':'足あとが多い館'}
INITIAL={'A':(0,0,0),'B':(1,0,1),'C':(2,1,0)}
ITEMS=('月のティーカップ','星のオルゴール','朝の小さな鏡')
ROUTES=('上の窓','中央の扉','下の窓');ACTIONS=('up','down','left','right','wait','use')
FLOOR=frozenset(p for p in range(35) if p%7 in (0,6) or p//7 in (0,2,4))
@dataclass(frozen=True)
class State:
 case:str='A';variant:str='adaptive';night:int=0;pos:int=14;holding:bool=False;t:int=0;guards:tuple=(0,0,0);used:int=0;items:tuple=();phase:str='play';fails:int=0;total:int=0;previous_used:int=0;trace:tuple=(14,);note:str='今夜はどの道から?'
def new(case='A',variant='adaptive'):
 if case not in INITIAL or variant not in ('adaptive','fixed'):raise ValueError('invalid setup')
 return State(case=case,variant=variant,guards=INITIAL[case])
def occupied(level,t):return level==1 and t%2==0 or level==2 and t%3 in (0,1)
def forecast(s):return tuple(tuple(occupied(g,s.t+k) for g in s.guards) for k in range(1,4))
def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards))
def retry(s,note='この夜だけ最初から。前夜までの品はそのまま。',failure=False,spent=0):
 if s.phase!='play':return s
 return replace(s,pos=START,holding=False,t=0,used=0,trace=(START,),fails=s.fails+int(failure),total=s.total+spent,note=note)
def step(s,a):
 if a=='next' and s.phase=='intermission':
  return replace(s,night=s.night+1,pos=START,holding=False,t=0,guards=adapted(s.guards,s.used) if s.variant=='adaptive' else s.guards,previous_used=s.used,used=0,phase='play',trace=(START,),note='昨日の足あとだけを覚えています。' if s.variant=='adaptive' else '比較版: 警備は初期値のまま。')
 if a=='retry':return retry(s)
 if s.phase!='play' or a not in ACTIONS:return s
 if a=='use' and not (s.pos==TREASURE and not s.holding or s.pos==START and s.holding):return s
 p=s.pos;d={'up':(0,-1),'down':(0,1),'left':(-1,0),'right':(1,0)}.get(a)
 if d:
  x,y=p%7+d[0],p//7+d[1];q=y*7+x
  if not(0<=x<7 and 0<=y<5 and q in FLOOR):return s
  p=q
 t=s.t+1
 if p in GATES and occupied(s.guards[GATES.index(p)],t):
  return retry(s,'見つかった! この夜の入口から、もう一度。',True,t)
 used=s.used|(1<<GATES.index(p) if p in GATES else 0);holding=s.holding;items=s.items;phase=s.phase;total=s.total
 note='待って、次の番人の位置を読む。' if a=='wait' else '足音をひとつ。'
 if a=='use':
  if p==TREASURE:holding=True;note=ITEMS[s.night]+'を包んだ。入口へ戻ろう。'
  else:
   holding=False;items=items+(s.night,);phase='done' if s.night==2 else 'intermission';total+=t;note='三品が部屋にそろいました。' if phase=='done' else ('帰還! 次の夜は、この足あとへ反応。' if s.variant=='adaptive' else '帰還! 次の夜も初期警備のまま。')
 if t>=40 and phase=='play':return retry(s,'40刻。今夜の入口から再試行できます。',True,t)
 return replace(s,pos=p,t=t,holding=holding,used=used,items=items,phase=phase,total=total,trace=s.trace+(p,),note=note)
def digest(s):return hashlib.sha256(json.dumps(asdict(s),ensure_ascii=False,sort_keys=True).encode()).hexdigest()
def from_dict(d):
 if not isinstance(d,dict) or set(d)!={f.name for f in fields(State)}:raise ValueError('state fields')
 d=dict(d)
 for k in ('guards','items','trace'):
  if not isinstance(d[k],list):raise ValueError('tuple field')
  d[k]=tuple(d[k])
 s=State(**d)
 if s.case not in INITIAL or s.variant not in ('adaptive','fixed') or s.phase not in ('play','intermission','done'):raise ValueError('enum')
 for k,lo,hi in (('night',0,2),('pos',0,34),('t',0,40),('used',0,7),('previous_used',0,7),('fails',0,100000),('total',0,10000000)):
  if type(getattr(s,k)) is not int or not lo<=getattr(s,k)<=hi:raise ValueError(k)
 if type(s.holding) is not bool or not isinstance(s.note,str) or len(s.note)>200 or s.pos not in FLOOR:raise ValueError('state value')
 if len(s.guards)!=3 or any(type(x) is not int or x not in range(3) for x in s.guards):raise ValueError('guards')
 if s.items!=tuple(range(s.night+(s.phase!='play'))):raise ValueError('items')
 if s.phase=='intermission' and s.night==2 or s.phase=='done' and s.night!=2:raise ValueError('phase')
 if not 1<=len(s.trace)<=41 or any(type(x) is not int or x not in FLOOR for x in s.trace):raise ValueError('trace')
 if s.trace[-1]!=s.pos or len(s.trace)!=s.t+1:raise ValueError('trace clock')
 return s

"""UTF-8 atomic local save. Validate fully before replacing the live controller."""
import json,os
from pathlib import Path
from dataclasses import asdict

def save(path,state,history,commands):
 p=Path(path);data={'game_id':GAME_ID,'version':VERSION,'case':state.case,'variant':state.variant,'state':asdict(state),'history':[asdict(s) for s in history],'semantic_inputs':commands,'settings':{'silent':True}}
 temp=p.with_suffix(p.suffix+'.tmp')
 with temp.open('w',encoding='utf-8',newline='\n') as f:json.dump(data,f,ensure_ascii=False,indent=2);f.flush();os.fsync(f.fileno())
 os.replace(temp,p)
def load(path):
 p=Path(path)
 if p.stat().st_size>8000000:raise ValueError('保存が大きすぎます')
 d=json.loads(p.read_text(encoding='utf-8'))
 if not isinstance(d,dict) or d.get('game_id')!=GAME_ID or d.get('version')!=VERSION:raise ValueError('未対応の作品または版です')
 s=from_dict(d['state']);h=d['history'];c=d['semantic_inputs']
 if d['case']!=s.case or d['variant']!=s.variant or not isinstance(h,list) or len(h)>5000 or not isinstance(c,list) or len(c)>10000 or any(not isinstance(x,str) or len(x)>40 for x in c):raise ValueError('記録の形式が違います')
 history=[from_dict(x) for x in h]
 if any(x.case!=s.case or x.variant!=s.variant for x in history):raise ValueError('履歴の状況が違います')
 return s,history,c

class storage:
    save = staticmethod(save)
    load = staticmethod(load)

KEYS={'SPACE','RETURN','Z','X','P','H','R','N','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','S','L','F12','ESCAPE'}
class Controller:
 def __init__(self,path='save_093.json'):
  self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.selected='right'
 def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.selected='right'
 def apply(self,a):
  if a=='undo':
   if not self.history:return 'ignored:undo'
   self.s=self.history.pop();self.log.append(a);return a
  old=self.s;self.s=step(old,a)
  if self.s==old:return 'ignored:'+a
  self.history.append(old);self.log.append(a);return a
 def feed(self,down):
  down=set(down)&KEYS;fresh=down-self.previous;self.previous=down
  if self.gate:
   if not down:self.gate=False
   return None
  if not fresh:return None
  self.gate=True
  if len(down)>1:return 'reject:chord'
  k=next(iter(fresh));self.notice=''
  if k=='F12':return 'screenshot'
  if self.dialog:
   if k in ('X','ESCAPE'):self.dialog='';return 'cancel'
   if k=='RETURN':
    d=self.dialog;self.dialog=''
    if d=='quit':return 'quit'
    if d=='new':self.reset();return 'reset:campaign'
    if d=='retry':return self.apply('retry')
    if d=='menu':self.title=True;return 'menu'
   return 'ignored:dialog'
  if k=='ESCAPE':self.dialog='quit';return 'confirm:quit'
  if k in ('P','H'):self.help=not self.help;return 'help'
  if self.help:return 'ignored:help'
  if self.title:
   if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
   if k=='B':self.s=new(self.s.case,'fixed' if self.s.variant=='adaptive' else 'adaptive');self.history=[];self.log=[];return 'variant'
   if k in ('SPACE','RETURN'):self.title=False;return 'start'
   return 'ignored:title'
  if k=='M':self.dialog='menu';return 'confirm:menu'
  if k=='N':self.dialog='new';return 'confirm:new'
  if k=='R' and self.s.phase=='play':self.dialog='retry';return 'confirm:retry'
  if k=='X':return self.apply('undo')
  if k in ('S','L'):
   if self.s.phase not in ('intermission','done'):self.notice='保存と読込は夜と夜の間、または三夜の結果で。';return 'blocked:save-phase'
   try:
    if k=='S':storage.save(self.path,self.s,self.history,self.log);self.notice='この三夜を保存しました。';return 'save'
    self.s,self.history,self.log=storage.load(self.path);self.notice='保存した三夜を読み込みました。';return 'load'
   except (ValueError,KeyError,TypeError,OSError):self.notice='保存を読めません。元のファイルは残しています。Nで新規開始。';return 'save-error'
  if self.s.phase=='intermission' and k=='RETURN':return self.apply('next')
  if self.s.phase!='play':return 'ignored:result'
  a={'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(k)
  if a:self.selected=a;return 'select:'+a
  if k=='RETURN':return self.apply(self.selected)
  return 'ignored:key'

# UI and diagnostic CLI: independent of the pure model.
import argparse
import json
from pathlib import Path

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())  # Every distinct key press has a full release.
    return event

def commit(ui, action):
    if action == 'next':
        return tap(ui, 'RETURN')
    key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action]
    tap(ui, key)
    return tap(ui, 'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        tap(ui, 'RETURN')
    if scenario == 'checkpoint':
        commit(ui, 'right')
        return
    if scenario == 'wall':
        commit(ui, 'right'); commit(ui, 'up')
    elif scenario in ('caught', 'preview') and STAGE >= 2:
        if STAGE >= 4:
            ui.s = new('A')
        ui.s = replace(ui.s, guards=(0, 1, 0))
        commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait')
        tap(ui, 'RIGHT')
        if scenario == 'caught':
            tap(ui, 'RETURN')
    elif scenario == 'timeout' and STAGE >= 2:
        for _ in range(40):
            commit(ui, 'wait')
    elif scenario == 'confirm':
        commit(ui, 'right'); tap(ui, 'R')
    elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3:
        nights = 3 if scenario == 'success' and STAGE >= 4 else 1
        for night in range(nights):
            if night:
                commit(ui, 'next')
            # A deterministic public model route, waiting before occupied gates.
            for direction in ('right', 'left'):
                for _ in range(6):
                    q = ui.s.pos + (1 if direction == 'right' else -1)
                    while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1):
                        commit(ui, 'wait')
                    commit(ui, direction)
                if direction == 'left' and scenario == 'return40':
                    while ui.s.t < 39:
                        commit(ui, 'wait')
                commit(ui, 'use')
        if scenario in ('next', 'undo_next') and STAGE >= 4:
            commit(ui, 'next')
            if scenario == 'undo_next':
                tap(ui, 'X')
        if scenario in ('saved','loaded') and STAGE == 5:
            tap(ui, 'S' if scenario == 'saved' else 'L')
    else:
        raise ValueError('scenario not available at this stage')

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] +
                        (['caught','preview','timeout'] if STAGE >= 2 else []) +
                        (['returned','return40'] if STAGE >= 3 else []) +
                        (['next','undo_next','success'] if STAGE >= 4 else []) +
                        (['saved','loaded'] if STAGE == 5 else []))
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--state-json', type=Path)
    parser.add_argument('--save-file', type=Path, default=Path('save_093.json'))
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json):
        parser.error('diagnostic flags require --headless-smoke')
    if args.demo_checkpoint and args.scenario:
        parser.error('choose --demo-checkpoint or --scenario')
    # Resolve before pyxel.init, which can alter working-directory behavior.
    for name, suffix in (('screenshot','.png'),('state_json','.json')):
        path = getattr(args, name)
        if path is None:
            continue
        path = path.resolve()
        if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists():
            parser.error(name + ': require a new file with correct suffix in an existing directory')
        setattr(args, name, path)
    args.save_file = args.save_file.resolve()
    if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():
        parser.error('save-file: require a .json file in an existing directory')
    if args.save_file in (args.screenshot, args.state_json):
        parser.error('save-file and diagnostic outputs must differ')
    if args.scenario == 'saved' and args.save_file.exists():
        parser.error('saved scenario must use a new save file')
    return args

class App:
    def __init__(self, args):
        self.args = args
        self.ui = Controller(args.save_file)
        self.frame = 0
        if args.headless_smoke:
            demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        pyxel.init(560, 400, title='093 THREE NIGHTS / STAGE ' + str(STAGE),
                   fps=30, quit_key=pyxel.KEY_NONE, headless=args.headless_smoke, display_scale=1)
        self.keys = {key: getattr(pyxel, 'KEY_' + key) for key in KEYS}
        pyxel.run(self.update, self.draw)

    def update(self):
        self.frame += 1
        if self.args.headless_smoke:
            if self.frame >= 3:
                pyxel.quit()
            return
        event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)})
        if event == 'quit':
            pyxel.quit()

    def draw(self):
        u, s = self.ui, self.ui.s
        pyxel.cls(1)
        pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10)
        pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + '   TICK: ' + str(s.t) + '   POS: ' + str(s.pos), 7)
        pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + '  PHASE: ' + getattr(s,'phase','play').upper(), 11)
        for p in range(35):
            x, y = 12 + p % 7 * 42, 48 + p // 7 * 42
            pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2)
            if p in FLOOR:
                pyxel.rectb(x, y, 39, 39, 13)
            if STAGE >= 2 and p in GATES:
                g = s.guards[GATES.index(p)]
                pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11)
                pyxel.text(x+5,y+28,'G'+str(g),7)
            if p == START:
                pyxel.text(x+3,y+29,'S',11)
            if STAGE >= 3 and p == TREASURE:
                pyxel.text(x+3,y+29,'T',10)
                if not s.holding and s.phase == 'play':
                    pyxel.circ(x+25,y+16,5,10)
        x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14
        pyxel.circ(x,y,6,12)
        if getattr(s,'holding',False):
            pyxel.circ(x+9,y,3,10)
        pyxel.rect(315,48,233,210,2)
        if STAGE >= 2:
            pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10)
            for i,g in enumerate(s.guards):
                future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3))
                pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7)
            pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7)
        if STAGE >= 3:
            pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10)
            pyxel.text(325,179,'HOME: '+str(len(s.items))+'  USED BITS: '+str(s.used),11)
        phase = getattr(s,'phase','play')
        if phase == 'play':
            preview = step(s,u.selected)
            msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK'
            pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6)
            pyxel.text(325,225,'ENTER commits; preview is read-only.',7)
        else:
            pyxel.text(325,208,'RESULT: '+phase.upper(),10)
            if phase == 'intermission':
                next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards
                pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11)
            else:
                pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11)
        pyxel.text(12,272,'Arrows: select direction   ENTER: commit   Release ALL keys between presses',7)
        if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait   R: confirm retry (current night only)',6)
        if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return   X: Undo one committed decision',6)
        if STAGE >= 4: pyxel.text(12,320,'N: new  M: menu  H/P: help  Title: 1/2/3 case, B variant',6)
        if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6)
        pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.notice:
            notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only'
            pyxel.text(12,375,notice,10)
        if u.title or u.help:
            pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10)
            pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10)
            pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7)
            pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6)
            pyxel.text(50,155,'Case: '+s.case+'   Variant: '+s.variant,11)
            pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7)
            pyxel.text(50,205,'ENTER: start  H/P: close help. Release keys first.',10)
        if u.dialog:
            pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8)
            pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10)
            pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7)
            pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6)
        if self.args.headless_smoke and self.frame == 2:
            if self.args.screenshot:
                pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title,
                      'dialog':u.dialog,'history':[asdict(v) for v in u.history],
                      'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'}
                with self.args.state_json.open('x',encoding='utf-8') as handle:
                    json.dump(data,handle,ensure_ascii=False,indent=2)

if __name__ == '__main__':
    args = parse_args()
    import pyxel
    App(args)
AFTER STEP 05

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

ルールのまとめ:新しい刻の、見せている警備の位置と、動いた先が重なれば、今夜をやり直し。品物を取ることと、帰ることは別の行動。前の夜に使った門だけ次の夜に +1、使わなかった門は -1、範囲は0〜2。40刻目に帰れれば成功。

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

比べる点5ステップの教材元の完成版
段階01 移動だけ、02 は真ん中の警備1、03 は元のゲームの状況 A の1夜、04 3つの夜、05 元のゲームのルール全部と保存。最初から、3つの夜とすべての機能があります。
表示・記録560×400の英数字(ASCII)。F12・元のゲームの出来事の記録・日本語の描画・飾りは省略。1024×720の日本語の画面、F12・実際の入力やコードの記録。
単独実行それぞれの Python ファイルに、ルール・必要な入力の処理・MIT ライセンスの全文。外部に必要なのは Pyxel だけで、assets は不要。いくつもの Python ファイルと glyphs.json。

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

  1. 元のゲームの model.py:step の移動の候補と FLOOR
  2. 元のゲームの controller.py:feed の fresh と gate
  3. 元のゲームの model.py:occupied/forecast/retry
  4. 元のゲームの app.py:preview.fails と warning
  5. 元のゲームの model.py:step の holding/items/phase
  6. 元のゲームの controller.py:apply の history
  7. 元のゲームの model.py:adapted と、step の next の分かれ道
  8. 元のゲームの experiments.py:campaign/all_guards
  9. 元のゲームの storage.py:save/load
  10. 元のゲームの model.py:from_dict/digest
  11. 元のゲームの app.py:record/flush_events/text
自分のゲームに変えるなら

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

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