探索・アドベンチャーLESSON 97閉店と帰路を考える六刻の祭りパズル原作094

お祭りのあと一時間の作り方

お店を閉めるほど、帰り道が増えていく。
閉店で変わる道を「一番短い道」で調べ、お客さんを前の位置から同時に動かします。6刻(時間の区切り)の終わりの境目、実際に起きたことにもとづくお別れの文と、Undo・保存を作ります。

完成版の遊び方10分ずつの6つの枠の中で3つのお店を閉め、開いた脇道や案内を使って、3人のお客さんを見送ります。

お祭りのあと一時間の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版A、三店を閉じて全員を見送った60分時点です。自動操作GIFから画素を変えず取り出した一コマで、人の試遊ではありません。教材は単独の道から始め、閉店・同時移動・別れの文を段階的に加えます。
PLAY / 試験公開

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

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

完成版の主な操作:Tab閉店・案内・待機、矢印対象/Enter一刻、Z会話、X戻す、S保存/L読込

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

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

うまく表示されないとき

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

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

閉店と、同時の移動を、順番に確定する。

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

1. 点と道のつながり(グラフ)・ゴールから逆向きの幅優先探索・同点のときのルール
2. 前の位置を全員で使う・買い物のあとで帰る・案内は2歩
3. 道が増えること・売り上げ・6刻の境目と片づけ
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python の関数・if・for / tuple・辞書・deque・dataclass/replace
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Enter:選択を一刻確定。walkoutでは一歩 / 左右/上下:対象を選択、01は地点、02以降は客か店 / Tab:02案内/待つ、03以降閉店/案内/待つ / Space:playでは待つ選択、04以降walkoutで自動切替 / X:04以降Undo、会話中は閉じる、確認中は取消 / Z/H/P/M:04以降で会話/説明/表紙 / S/L:05で通常ゲーム画面から保存読込 / R/Esc:確認して新規/終了。Rはコード再読込ではない
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_routes.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

六つの地点から最短路を作る

点と道のつながり(グラフ)・ゴールから逆向きの幅優先探索・同点のときのルール。

広場を、6つの地点と、道の組み合わせに分けます。0 出口、1 茶屋の前、2 花火屋の前、3 橋、4 麺屋の前、5 舞台です。PERMANENT(いつもある道)は、(0,3),(3,4),(4,5),(5,2),(2,1),(1,0) の6本です。画面で近く見えるかで距離を計算せず、道1本を1歩として数えます。線が交差していても、交差点は追加しません。

01 には、1人ぶんの位置 pos と、動いた回数 t だけがあり、買い物・閉店・売り上げはありません。最初の位置は5、選んでいる目的地は0です。左右キーで目的地の番号を選んでも、状態は変わりません。Enter で、一番短い道の最初の1本だけを進みます。目的地に着いてから Enter を押しても、何も変わりません。この段階の t は、営業時間ではなく、有効な歩数です。

route は、目的地から幅優先探索(近い順に広げていく探し方)をして、距離の表 dist を作ります。そこから、今の地点 p のとなりで、距離が1つ小さい候補を選びます。同じ距離なら一番小さい地点の番号を選ぶので、5から0は 2,1,0 です。辞書に入れた順番に頼らず、min とはっきり書くと、毎回同じ結果を再現できます。道の計算は State を変えません。

Controller の入力の待ち・確認の画面、App の描画・確認用のコマンド(CLI)は、それぞれのファイルが単独で動くための足場です。history の配列は 04 につなぐために残していますが、01〜03 では Undo の操作は使えません。それぞれの段階に完成したゲーム全部を隠しているわけではなく、01 は pos/t だけのルールです。

キーは1回押すごとに、全部離してください。矢印・Tab・Space は選ぶだけで、Enter で確定します。エディターでコードを保存したら、Esc → 全部離す → Enter で終了して、同じコマンドで実行し直します。R はゲームの状態を最初に戻すだけで、Python のコードを読み直すわけではありません。

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

01_routes.py / 注目する部分
    result, p = [], start
    while p != target:
        p = min(n for n in graph[p] if dist[n] == dist[p] - 1)
        result.append(p)
    return tuple(result)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_routes.py
macOS / Linux
.venv/bin/python 01_routes.py
画面で見ること最初は POSITION=5、TARGET=0、PATH=(2,1,0) です。Enter → 離す を3回押すと 2→1→0 と進み、TICK=3 です。4回目の Enter → 離す では、0・3 のままです。
  • 距離は、道の本数で数える
  • 同点のときのルールを、はっきり書く
  • 着いたあとの無効な入力では、何も変わらない
つまずきやすいところ画面上のまっすぐな距離で、一番短い道を決めないでください。道の交差も、新しい地点ではありません。目的地からの dist を、先に完成させます。
少し変えてみよう

route の中の p = min(n for n in graph[p] if dist[n] == dist[p] - 1) を p = max(n for n in graph[p] if dist[n] == dist[p] - 1) に変えてみましょう。保存 → 終了 → 実行し直します。目的地0のまま Enter → 離す を3回押すと、5→4→3→0 と進みます。同じ3歩でも、同点のときのルールで通る景色が変わります。python exercises/01_tiebreak.py で、書き換えを実行できます。最後に min に戻します。

01_routes.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(377行)
01_routes.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
from collections import deque
PERMANENT = ((0,3),(3,4),(4,5),(5,2),(2,1),(1,0))
SHOP_POS = (1,4,2)
@dataclass(frozen=True)
class State:
    pos: int = 5
    t: int = 0

def new():
    return State()
def route(s, start, target):
    if start == target:
        return ()
    graph = {i: [] for i in range(6)}
    for a, b in PERMANENT:
        graph[a].append(b)
        graph[b].append(a)
    dist = {target: 0}
    queue = deque([target])
    while queue:
        p = queue.popleft()
        for n in graph[p]:
            if n not in dist:
                dist[n] = dist[p] + 1
                queue.append(n)
    result, p = [], start
    while p != target:
        p = min(n for n in graph[p] if dist[n] == dist[p] - 1)
        result.append(p)
    return tuple(result)

def step(s, target):
    if type(target) is not int or target not in range(6):
        return s
    path = route(s, s.pos, target)
    if not path:
        return s
    return replace(s, pos=path[0], t=s.t + 1)

KEYS = {'RETURN','LEFT','RIGHT','UP','DOWN','TAB','SPACE','X','R','ESCAPE'}
class Controller:
    def __init__(self, path=None):
        self.s = new()
        self.mode = 0 if STAGE in (1,3) else 1
        self.target = 0
        self.previous = set()
        self.gate = False
        self.dialog = ''
        self.title = False
        self.help = False
        self.detail = False
        self.auto = False
        self.history = []
        self.log = []
        self.notice = ''

    def selection(self):
        if STAGE == 1:
            return self.target
        return 'close:' + str(self.target) if self.mode == 0 else 'guide:' + str(self.target) if self.mode == 1 else 'wait'

    def apply(self, action):
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            return 'ignored'
        self.history.append(old)
        return 'commit'

    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'
                self.s = new()
                self.history = []
                return 'reset'
            return 'ignored:dialog'
        if key == 'ESCAPE':
            self.dialog = 'quit'
            return 'confirm:quit'
        if key == 'R':
            self.dialog = 'retry'
            return 'confirm:retry'
        if getattr(self.s,'phase','play') == 'walkout':
            return self.apply('walk') if key == 'RETURN' else 'ignored:walkout'
        if getattr(self.s,'phase','play') == 'done':
            return 'ignored:done'
        if key in ('LEFT','UP','RIGHT','DOWN'):
            self.target = (self.target + (1 if key in ('RIGHT','DOWN') else -1)) % (6 if STAGE == 1 else 3)
            return 'select:target'
        if STAGE >= 2 and key == 'TAB':
            self.mode = (1 if self.mode == 2 else 2) if STAGE == 2 else (self.mode + 1) % 3
            return 'select:mode'
        if STAGE >= 2 and key == 'SPACE':
            self.mode = 2
            return 'select:wait'
        if key == 'RETURN':
            return self.apply(self.selection())
        return 'ignored:key'

import argparse
import json
from pathlib import Path
POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232))
COLORS = (10,12,14)
SHOP_LABELS = ('TEA','MASK','FIREWORK')

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())
    return event

def choose(ui, action):
    if STAGE == 1:
        while ui.target != action:
            tap(ui,'RIGHT')
    elif action == 'walk':
        pass
    elif action == 'wait':
        tap(ui,'SPACE')
    else:
        mode = 0 if action.startswith('close:') else 1
        while ui.mode != mode:
            tap(ui,'TAB')
        target = int(action[-1])
        while ui.target != target:
            tap(ui,'RIGHT')

def commit(ui, action):
    choose(ui, action)
    return tap(ui,'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        if scenario in ('case_b','case_c','fixed'):
            tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario])
        tap(ui,'RETURN')
    if scenario in ('case_b','case_c','fixed') and STAGE >= 4:
        commit(ui,'close:0' if scenario == 'fixed' else 'wait')
        return
    if scenario == 'finish_practice' and STAGE == 2:
        for _ in range(6):commit(ui,'wait')
        return
    if scenario == 'loaded' and STAGE == 5:
        tap(ui,'L')
        return
    if scenario == 'checkpoint':
        commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0')
    elif scenario == 'confirm':
        commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R')
    elif scenario == 'arrived' and STAGE == 1:
        for _ in range(3):commit(ui,0)
    elif scenario == 'guide' and STAGE >= 2:
        commit(ui,'guide:0')
    elif scenario == 'preview' and STAGE >= 2:
        choose(ui,'guide:0')
    elif scenario in ('closed','invalid_close') and STAGE >= 3:
        commit(ui,'close:2')
        if scenario == 'invalid_close':commit(ui,'close:2')
    elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3:
        if scenario in ('walkout','auto_help','auto_detail','auto_done'):
            # Explicit constructed boundary, not presented as a reachable natural campaign.
            ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2)))
            commit(ui,'wait')
        elif scenario == 'cleanup':
            for _ in range(6):commit(ui,'wait')
        else:
            for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]:
                commit(ui,action)
        if scenario in ('success','cleanup','saved','loaded'):
            while ui.s.phase == 'walkout':commit(ui,'walk')
        if scenario == 'undo':tap(ui,'X')
        if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L')
        if scenario.startswith('auto_'):
            tap(ui,'SPACE')
            if scenario == 'auto_help':tap(ui,'H')
            if scenario == 'auto_detail':tap(ui,'Z')
    elif scenario == 'detail' and STAGE >= 4:
        tap(ui,'Z')
    else:
        raise ValueError('scenario unavailable')

def parse_args(argv=None):
    p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094')
    p.add_argument('--headless-smoke',action='store_true')
    p.add_argument('--demo-checkpoint',action='store_true')
    scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview'])
    if STAGE==2:scenarios+=['finish_practice']
    if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout']
    if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed']
    if STAGE==5:scenarios+=['saved','loaded']
    p.add_argument('--scenario',choices=scenarios)
    p.add_argument('--screenshot',type=Path)
    p.add_argument('--state-json',type=Path)
    p.add_argument('--save-file',type=Path,default=Path('save_094.json'))
    a=p.parse_args(argv)
    if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke')
    if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario')
    for name,suffix in (('screenshot','.png'),('state_json','.json')):
        path=getattr(a,name)
        if path is None:continue
        path=path.resolve()
        if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory')
        setattr(a,name,path)
    a.save_file=a.save_file.resolve()
    if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory')
    if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ')
    if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file')
    return a

class App:
    def __init__(self,args):
        self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0
        if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2
        pyxel.init(600,420,title='094 FESTIVAL / 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 advance_auto(self):
        u=self.ui
        if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate):
            self.auto_clock+=1
            if self.auto_clock>=30:
                self.auto_clock=0;u.apply('walk')
        else:self.auto_clock=0

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

    def draw(self):
        u,s=self.ui,self.ui.s;pyxel.cls(1)
        pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10)
        pyxel.text(12,25,'TICK: '+str(s.t)+'  PHASE: '+getattr(s,'phase','walk').upper(),7)
        edge_list=edges(s) if STAGE>=3 else PERMANENT
        for a,b in edge_list:
            x,y=POINTS[a];xx,yy=POINTS[b]
            pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5)
        for i,(x,y) in enumerate(POINTS):
            pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6)
            # Solid label background prevents graph edges crossing text.
            pyxel.rect(x-20,y+21,62,10,1)
            pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7)
        guests=() if STAGE==1 else s.guests
        if STAGE==1:
            x,y=POINTS[s.pos];pyxel.circ(x,y,5,10)
        else:
            for i,g in enumerate(guests):
                x,y=POINTS[g.pos];x+=(i-1)*9
                if g.exited:pyxel.circb(x,y,4,COLORS[i])
                else:pyxel.circ(x,y,4,COLORS[i])
        pyxel.rect(345,48,243,228,2)
        if STAGE==1:
            pyxel.text(355,60,'POSITION: '+str(s.pos)+'  TARGET: '+str(u.target),10)
            pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7)
            pyxel.text(355,104,'Shortest path; small next node wins ties.',6)
        else:
            for i,g in enumerate(guests):
                y=60+i*48
                pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i])
                pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7)
                pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6)
        if STAGE>=3:
            pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11)
            pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7)
            pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7)
        phase=getattr(s,'phase','play')
        pyxel.rect(12,286,576,42,2)
        if phase=='play':
            selected=u.selection();preview=step(s,selected)
            pyxel.text(20,295,'SELECT: '+str(selected)+'  PREVIEW ONLY',10)
            if STAGE==1:message='NEXT POS '+str(preview.pos)
            elif preview==s:message='INVALID / no time or history change'
            else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+'  NEXT TICK '+str(preview.t)
            pyxel.text(20,312,message,6)
        elif phase=='walkout':
            pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10)
            pyxel.text(20,312,'ENTER: next step  SPACE: auto (stage 4+) / no more sales',7)
        else:
            achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests)
            pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11)
            pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7)
        pyxel.text(12,342,'Arrows: target  ENTER: commit one step  R: confirm restart',7)
        if STAGE>=2:pyxel.text(12,358,'TAB: mode  SPACE: select wait  Release ALL keys between presses',6)
        if STAGE>=4:pyxel.text(12,374,'X: Undo  Z: detail  H/P: help  M: menu  Title 1/2/3 + B',6)
        if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6)
        pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.title or u.help:
            pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10)
            pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10)
            pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7)
            pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7)
            pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7)
            pyxel.text(50,176,'Case '+s.case+'  Variant '+s.variant+' / 1,2,3 case; B variant',11)
            pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10)
            pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6)
        if u.detail:
            g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11)
            pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10)
            pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+'  POS '+str(g.pos)+'  '+g.status.upper(),7)
            pyxel.text(50,132,'GUIDED '+str(g.guided)+'  SHORTCUT BITS '+str(g.shortcuts),7)
            pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+'  LAST SELF-CLOSED '+str(s.last),7)
            pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6)
            pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10)
        if u.dialog:
            pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8)
            pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10)
            pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7)
            pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6)
        if u.notice:
            msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved'
            pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10)
        if self.args.headless_smoke and self.frame==self.capture_frame:
            if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'}
                with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2)
if __name__=='__main__':
    args=parse_args()
    import pyxel
    App(args)
STEP 02 / 05

三人の希望を変えず同時に歩かせる

前の位置を全員で使う・買い物のあとで帰る・案内は2歩。

02 で、元のゲームの状況 A の3人を加えます。G0 ユイは舞台5から茶屋1へ、G1 タクは橋3から麺屋4へ、G2 エンは花火屋の前2で花火屋2に寄りたいお客さんです。wish(希望)は本人の希望で変わらず、goal(目的地)は、pending(まだ買っていない)ならそのお店、それ以外なら出口0を返します。行き先は最初から見えています。案内や会話で、隠れた目的が明らかになる仕掛けではありません。

最初のモードは guide:0(0番を案内)です。Enter で、全員が前の位置から同時に歩き、案内された人だけ最大2本の道を進みます。移動した先を1人ずつ新しい State に書いて、あとの人の計画に混ぜることはせず、guests に結果を集めて、最後に tuple にします。人どうしは道をふさぎません。希望のお店に着いたら bought(買った)で止まり、出口に向かうのは次の刻からです。2歩の案内で歩数が余っても、買い物のあとの帰り道には使いません。

Tab で「案内」と「待つ」の2つのモードを切り替え、左右キーで相手の0〜2を選び、Space で「待つ」を選びます。全員が帰っても、6刻までは待てます。02 はお店を全部開けたままにしていて、閉店・売り上げ・脇道・見送り・帰り道の再生はまだありません。6刻で練習を終える、簡単にしたルールです。03 で、6刻のあとの実際の後片づけと、帰り道を加えます。

キーは1回押すごとに、全部離してください。矢印・Tab・Space は選ぶだけで、Enter で確定します。エディターでコードを保存したら、Esc → 全部離す → Enter で終了して、同じコマンドで実行し直します。R はゲームの状態を最初に戻すだけで、Python のコードを読み直すわけではありません。

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

02_guest_wishes.py / 注目する部分
        if g.status == 'pending' and g.pos == target:
            g = replace(g, status='bought')
        elif g.status != 'pending' and g.pos == 0:
            g = replace(g, exited=True)
        guests.append(g)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_guest_wishes.py
macOS / Linux
.venv/bin/python 02_guest_wishes.py
画面で見ること最初の guide:0 で Enter → 離す と押します。G0 は1で BOUGHT、G1 は4で BOUGHT、G2 は2で BOUGHT、TICK=1 です。案内された G0 も、まだ HOME(帰宅)ではありません。Space → 離す → Enter → 離す で、G0 は0で HOME、G1 は3、G2 は1 です。
  • 希望は変わらず、案内で変わるのは歩数だけ
  • 全員が、前の位置から同時に動く
  • 買い物と帰り始めは、別の刻にする
つまずきやすいところ案内する相手に、新しい wish を代入しないでください。着いたかの判定を2つの独立した if にすると、買い物と帰り始めを、同じ刻に続けて処理してしまうおそれがあります。
少し変えてみよう

steps = path[:2 if guide == i else 1] の2だけを1に変えて、保存 → 終了 → 実行し直してみましょう。最初の guide:0 のまま Enter → 離す で、G0 は2で PENDING です。もう一度 Enter → 離す で1で BOUGHT。さらに Enter → 離す で0で HOME です。元の2歩の版は、最初の1刻で買い物まで着きます。python exercises/02_guidance.py で、2つの版の違いを確かめます。最後に2に戻します。

02_guest_wishes.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(409行)
02_guest_wishes.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
from collections import deque
PERMANENT = ((0,3),(3,4),(4,5),(5,2),(2,1),(1,0))
SHOP_POS = (1,4,2)
@dataclass(frozen=True)
class Guest:
    pos: int
    wish: int
    status: str = 'pending'
    exited: bool = False
    guided: bool = False

@dataclass(frozen=True)
class State:
    t: int = 0
    guests: tuple = ()
    phase: str = 'play'

def new():
    return State(guests=(Guest(5,0),Guest(3,1),Guest(2,2)))
def route(s, start, target):
    if start == target:
        return ()
    graph = {i: [] for i in range(6)}
    for a, b in PERMANENT:
        graph[a].append(b)
        graph[b].append(a)
    dist = {target: 0}
    queue = deque([target])
    while queue:
        p = queue.popleft()
        for n in graph[p]:
            if n not in dist:
                dist[n] = dist[p] + 1
                queue.append(n)
    result, p = [], start
    while p != target:
        p = min(n for n in graph[p] if dist[n] == dist[p] - 1)
        result.append(p)
    return tuple(result)

def goal(g):
    return SHOP_POS[g.wish] if g.status == 'pending' else 0

def legal(s):
    if s.phase != 'play':
        return ()
    return tuple('guide:' + str(i) for i, g in enumerate(s.guests) if not g.exited) + ('wait',)

def step(s, action):
    if action not in legal(s):
        return s
    guide = int(action[-1]) if action.startswith('guide:') else -1
    guests = []
    for i, old in enumerate(s.guests):
        if old.exited:
            guests.append(old)
            continue
        g = replace(old, guided=old.guided or guide == i)
        target = goal(g)
        path = route(s, g.pos, target)
        steps = path[:2 if guide == i else 1]
        if steps:
            g = replace(g, pos=steps[-1])
        if g.status == 'pending' and g.pos == target:
            g = replace(g, status='bought')
        elif g.status != 'pending' and g.pos == 0:
            g = replace(g, exited=True)
        guests.append(g)
    t = s.t + 1
    return replace(s, t=t, guests=tuple(guests), phase='done' if t == 6 else 'play')

KEYS = {'RETURN','LEFT','RIGHT','UP','DOWN','TAB','SPACE','X','R','ESCAPE'}
class Controller:
    def __init__(self, path=None):
        self.s = new()
        self.mode = 0 if STAGE in (1,3) else 1
        self.target = 0
        self.previous = set()
        self.gate = False
        self.dialog = ''
        self.title = False
        self.help = False
        self.detail = False
        self.auto = False
        self.history = []
        self.log = []
        self.notice = ''

    def selection(self):
        if STAGE == 1:
            return self.target
        return 'close:' + str(self.target) if self.mode == 0 else 'guide:' + str(self.target) if self.mode == 1 else 'wait'

    def apply(self, action):
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            return 'ignored'
        self.history.append(old)
        return 'commit'

    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'
                self.s = new()
                self.history = []
                return 'reset'
            return 'ignored:dialog'
        if key == 'ESCAPE':
            self.dialog = 'quit'
            return 'confirm:quit'
        if key == 'R':
            self.dialog = 'retry'
            return 'confirm:retry'
        if getattr(self.s,'phase','play') == 'walkout':
            return self.apply('walk') if key == 'RETURN' else 'ignored:walkout'
        if getattr(self.s,'phase','play') == 'done':
            return 'ignored:done'
        if key in ('LEFT','UP','RIGHT','DOWN'):
            self.target = (self.target + (1 if key in ('RIGHT','DOWN') else -1)) % (6 if STAGE == 1 else 3)
            return 'select:target'
        if STAGE >= 2 and key == 'TAB':
            self.mode = (1 if self.mode == 2 else 2) if STAGE == 2 else (self.mode + 1) % 3
            return 'select:mode'
        if STAGE >= 2 and key == 'SPACE':
            self.mode = 2
            return 'select:wait'
        if key == 'RETURN':
            return self.apply(self.selection())
        return 'ignored:key'

import argparse
import json
from pathlib import Path
POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232))
COLORS = (10,12,14)
SHOP_LABELS = ('TEA','MASK','FIREWORK')

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())
    return event

def choose(ui, action):
    if STAGE == 1:
        while ui.target != action:
            tap(ui,'RIGHT')
    elif action == 'walk':
        pass
    elif action == 'wait':
        tap(ui,'SPACE')
    else:
        mode = 0 if action.startswith('close:') else 1
        while ui.mode != mode:
            tap(ui,'TAB')
        target = int(action[-1])
        while ui.target != target:
            tap(ui,'RIGHT')

def commit(ui, action):
    choose(ui, action)
    return tap(ui,'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        if scenario in ('case_b','case_c','fixed'):
            tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario])
        tap(ui,'RETURN')
    if scenario in ('case_b','case_c','fixed') and STAGE >= 4:
        commit(ui,'close:0' if scenario == 'fixed' else 'wait')
        return
    if scenario == 'finish_practice' and STAGE == 2:
        for _ in range(6):commit(ui,'wait')
        return
    if scenario == 'loaded' and STAGE == 5:
        tap(ui,'L')
        return
    if scenario == 'checkpoint':
        commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0')
    elif scenario == 'confirm':
        commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R')
    elif scenario == 'arrived' and STAGE == 1:
        for _ in range(3):commit(ui,0)
    elif scenario == 'guide' and STAGE >= 2:
        commit(ui,'guide:0')
    elif scenario == 'preview' and STAGE >= 2:
        choose(ui,'guide:0')
    elif scenario in ('closed','invalid_close') and STAGE >= 3:
        commit(ui,'close:2')
        if scenario == 'invalid_close':commit(ui,'close:2')
    elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3:
        if scenario in ('walkout','auto_help','auto_detail','auto_done'):
            # Explicit constructed boundary, not presented as a reachable natural campaign.
            ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2)))
            commit(ui,'wait')
        elif scenario == 'cleanup':
            for _ in range(6):commit(ui,'wait')
        else:
            for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]:
                commit(ui,action)
        if scenario in ('success','cleanup','saved','loaded'):
            while ui.s.phase == 'walkout':commit(ui,'walk')
        if scenario == 'undo':tap(ui,'X')
        if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L')
        if scenario.startswith('auto_'):
            tap(ui,'SPACE')
            if scenario == 'auto_help':tap(ui,'H')
            if scenario == 'auto_detail':tap(ui,'Z')
    elif scenario == 'detail' and STAGE >= 4:
        tap(ui,'Z')
    else:
        raise ValueError('scenario unavailable')

def parse_args(argv=None):
    p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094')
    p.add_argument('--headless-smoke',action='store_true')
    p.add_argument('--demo-checkpoint',action='store_true')
    scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview'])
    if STAGE==2:scenarios+=['finish_practice']
    if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout']
    if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed']
    if STAGE==5:scenarios+=['saved','loaded']
    p.add_argument('--scenario',choices=scenarios)
    p.add_argument('--screenshot',type=Path)
    p.add_argument('--state-json',type=Path)
    p.add_argument('--save-file',type=Path,default=Path('save_094.json'))
    a=p.parse_args(argv)
    if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke')
    if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario')
    for name,suffix in (('screenshot','.png'),('state_json','.json')):
        path=getattr(a,name)
        if path is None:continue
        path=path.resolve()
        if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory')
        setattr(a,name,path)
    a.save_file=a.save_file.resolve()
    if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory')
    if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ')
    if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file')
    return a

class App:
    def __init__(self,args):
        self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0
        if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2
        pyxel.init(600,420,title='094 FESTIVAL / 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 advance_auto(self):
        u=self.ui
        if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate):
            self.auto_clock+=1
            if self.auto_clock>=30:
                self.auto_clock=0;u.apply('walk')
        else:self.auto_clock=0

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

    def draw(self):
        u,s=self.ui,self.ui.s;pyxel.cls(1)
        pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10)
        pyxel.text(12,25,'TICK: '+str(s.t)+'  PHASE: '+getattr(s,'phase','walk').upper(),7)
        edge_list=edges(s) if STAGE>=3 else PERMANENT
        for a,b in edge_list:
            x,y=POINTS[a];xx,yy=POINTS[b]
            pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5)
        for i,(x,y) in enumerate(POINTS):
            pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6)
            # Solid label background prevents graph edges crossing text.
            pyxel.rect(x-20,y+21,62,10,1)
            pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7)
        guests=() if STAGE==1 else s.guests
        if STAGE==1:
            x,y=POINTS[s.pos];pyxel.circ(x,y,5,10)
        else:
            for i,g in enumerate(guests):
                x,y=POINTS[g.pos];x+=(i-1)*9
                if g.exited:pyxel.circb(x,y,4,COLORS[i])
                else:pyxel.circ(x,y,4,COLORS[i])
        pyxel.rect(345,48,243,228,2)
        if STAGE==1:
            pyxel.text(355,60,'POSITION: '+str(s.pos)+'  TARGET: '+str(u.target),10)
            pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7)
            pyxel.text(355,104,'Shortest path; small next node wins ties.',6)
        else:
            for i,g in enumerate(guests):
                y=60+i*48
                pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i])
                pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7)
                pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6)
        if STAGE>=3:
            pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11)
            pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7)
            pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7)
        phase=getattr(s,'phase','play')
        pyxel.rect(12,286,576,42,2)
        if phase=='play':
            selected=u.selection();preview=step(s,selected)
            pyxel.text(20,295,'SELECT: '+str(selected)+'  PREVIEW ONLY',10)
            if STAGE==1:message='NEXT POS '+str(preview.pos)
            elif preview==s:message='INVALID / no time or history change'
            else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+'  NEXT TICK '+str(preview.t)
            pyxel.text(20,312,message,6)
        elif phase=='walkout':
            pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10)
            pyxel.text(20,312,'ENTER: next step  SPACE: auto (stage 4+) / no more sales',7)
        else:
            achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests)
            pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11)
            pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7)
        pyxel.text(12,342,'Arrows: target  ENTER: commit one step  R: confirm restart',7)
        if STAGE>=2:pyxel.text(12,358,'TAB: mode  SPACE: select wait  Release ALL keys between presses',6)
        if STAGE>=4:pyxel.text(12,374,'X: Undo  Z: detail  H/P: help  M: menu  Title 1/2/3 + B',6)
        if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6)
        pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.title or u.help:
            pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10)
            pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10)
            pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7)
            pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7)
            pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7)
            pyxel.text(50,176,'Case '+s.case+'  Variant '+s.variant+' / 1,2,3 case; B variant',11)
            pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10)
            pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6)
        if u.detail:
            g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11)
            pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10)
            pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+'  POS '+str(g.pos)+'  '+g.status.upper(),7)
            pyxel.text(50,132,'GUIDED '+str(g.guided)+'  SHORTCUT BITS '+str(g.shortcuts),7)
            pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+'  LAST SELF-CLOSED '+str(s.last),7)
            pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6)
            pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10)
        if u.dialog:
            pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8)
            pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10)
            pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7)
            pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6)
        if u.notice:
            msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved'
            pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10)
        if self.args.headless_smoke and self.frame==self.capture_frame:
            if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'}
                with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2)
if __name__=='__main__':
    args=parse_args()
    import pyxel
    App(args)
STEP 03 / 05

閉店を移動より先に確定する

道が増えること・売り上げ・6刻の境目と片づけ。

03 は、元のゲームの6刻の進み方、閉店、脇道、売り上げ、片づけ、帰り道を加えます。最初のモードは close:0(0番を閉める)です。Tab で「閉店 → 案内 → 待つ」、左右キーでお店やお客さんの0〜2を選びます。茶屋0を閉めると 0–4、麺屋1で 1–4、花火屋2で 1–5 の道が加わります。この道は屋台のわきの通り道です。画面で交差する線の上にラベルが重なっても、ラベルの背景で区別できます。

順番は、閉店 → 道を加える → 全員の前の位置から道を計算 → 同時に移動 → 買い物・帰り始め → 開いているお店の売り上げ → 刻を進める、です。閉めたお店を希望している pending のお客さんは、同じ刻に skipped(あきらめた)に変わり、出口へ向かいます。閉めた刻にお店に着いても、買い物はできません。開いているお店は、1刻ごとに売り上げが1増えます。買った人数を足す式ではありません。もう閉まっているお店への close は無効で、刻も履歴も変わりません。

6刻目の移動と売り上げのあと、開いているお店は片づけ係が閉めます。cleanup はそのことを記録し、closed_self は自分で閉めたお店のビットだけを持ちます。片づけて全員が帰っても、complete(達成)にはなりません。お客さんが残っていれば walkout(帰り道)に入り、Enter で帰り道を1歩だけ進めます。t と sales は変わらず、walk_t だけが増えます。終わりは done ですが、達成は「done、しかも自分で3つのお店を閉め、全員が帰った」ときです。

03 では状況 A を操作します。元のゲームの case/variant のデータ、guided/shortcuts/exit_t は、04 につなぐ記録の足場として、もう持っていて、JSON で確かめられます。ほかの状況と比較版の選択、お別れの文、Undo、会話、自動の再生は、04 で使えるようにします。

キーは1回押すごとに、全部離してください。矢印・Tab・Space は選ぶだけで、Enter で確定します。エディターでコードを保存したら、Esc → 全部離す → Enter で終了して、同じコマンドで実行し直します。R はゲームの状態を最初に戻すだけで、Python のコードを読み直すわけではありません。

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

03_close_paths.py / 注目する部分
 sales=tuple(v+int(o) for v,o in zip(s.sales,n.opened));t=s.t+1;phase='play';cleanup=False
 if t==6:
  cleanup=any(n.opened)
  if cleanup:notes.append('残りの店は清掃係が閉店。自分で三店の依頼は未了。')
  n=replace(n,opened=(False,False,False));phase='done' if all(g.exited for g in guests) else 'walkout'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_close_paths.py
macOS / Linux
.venv/bin/python 03_close_paths.py
画面で見ること右 → 離す を2回押して close:2 にします。Enter → 離す で花火屋は CLOSED、G2 は SKIPPED、sales=(1,1,0)、TICK=1 です。もう一度 Enter → 離す は無効で、1のままです。R → 離す → Enter → 離す で新しく始めます。Space → 離す → Enter → 離す を6回すると、sales=(6,6,6)、CLEANUP=1、SELF=0 で、基本の依頼は達成していません。
  • 閉店は、移動と買い物より前に処理する
  • 片づけは、6刻目の売り上げのあと
  • done(終わり)と complete(達成)を区別する
つまずきやすいところ売り上げは、買った人数ではありません。6刻目の売り上げより先に片づけると、まだ開いていたお店の最後の売り上げが抜けてしまいます。片づけで closed_self を7に変えてはいけません。
少し変えてみよう

step の sales=tuple(v+int(o) for v,o in zip(s.sales,n.opened)) を sales=tuple(v+2*int(o) for v,o in zip(s.sales,n.opened)) に変えてみましょう。保存 → 終了 → 実行し直します。最初の close:0 で Enter → 離す なら、sales=(0,2,2)、t1 です。同じ Enter → 離す は無効で変わりません。Space → 離す → Enter → 離す で sales=(0,4,4)、t2 です。python exercises/03_sales.py で実行できます。最後に int(o) に戻します。

03_close_paths.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(411行)
03_close_paths.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

"""Simultaneous guests follow their own wishes; closing stalls opens public paths."""
from dataclasses import dataclass,replace,asdict,fields
from collections import deque
import hashlib,json
GAME_ID='094';VERSION='094-1.0'
NAMES=('ユイ','タク','エン');STORES=('茶店','面屋','花火屋');PLACES=('出口','茶前','花火前','橋','面前','舞台');SHOP_POS=(1,4,2)
PERMANENT=((0,3),(3,4),(4,5),(5,2),(2,1),(1,0));SHORTCUTS=((0,4),(1,4),(1,5))
TITLES={'A':'あと一杯のお茶','B':'舞台の帰り','C':'橋の向こうの三人'}
INITIAL={'A':((5,0),(3,1),(2,2)),'B':((5,1),(5,0),(4,2)),'C':((3,2),(4,0),(5,1))}
@dataclass(frozen=True)
class Guest:
 pos:int;wish:int;status:str='pending';exited:bool=False;guided:bool=False;shortcuts:int=0;exit_t:int=-1
@dataclass(frozen=True)
class State:
 case:str='A';variant:str='paths';t:int=0;opened:tuple=(True,True,True);guests:tuple=();sales:tuple=(0,0,0);last:int=-1;closed_self:int=0;cleanup:bool=False;phase:str='play';walk_t:int=0;notes:tuple=('終わるお祭りに、どの道を残そう。',)
def new(case='A',variant='paths'):
 if case not in INITIAL or variant not in ('paths','fixed'):raise ValueError('setup')
 return State(case=case,variant=variant,guests=tuple(Guest(p,w) for p,w in INITIAL[case]))
def edges(s):return PERMANENT+tuple(e for i,e in enumerate(SHORTCUTS) if not s.opened[i] and s.variant=='paths')
def route(s,start,target):
 if start==target:return ()
 graph={i:[] for i in range(6)}
 for a,b in edges(s):graph[a].append(b);graph[b].append(a)
 dist={target:0};q=deque([target])
 while q:
  p=q.popleft()
  for n in graph[p]:
   if n not in dist:dist[n]=dist[p]+1;q.append(n)
 result=[];p=start
 while p!=target:p=min(n for n in graph[p] if dist[n]==dist[p]-1);result.append(p)
 return tuple(result)
def goal(g):return SHOP_POS[g.wish] if g.status=='pending' else 0

def move_guests(s,guide=-1,walkout=False):
 result=[];notes=[]
 for i,old in enumerate(s.guests):
  if old.exited:result.append(old);continue
  g=replace(old,guided=old.guided or guide==i)
  if g.status=='pending' and not s.opened[g.wish]:g=replace(g,status='skipped');notes.append(NAMES[i]+'の'+STORES[g.wish]+'は今回は見送り。')
  target=goal(g);path=route(s,g.pos,target);steps=path[:2 if guide==i else 1];prev=g.pos;bits=g.shortcuts
  for n in steps:
   for j,e in enumerate(SHORTCUTS):
    if s.variant=='paths' and not s.opened[j] and set((prev,n))==set(e):bits|=1<<j
   prev=n
  g=replace(g,pos=prev,shortcuts=bits)
  if g.status=='pending' and g.pos==target:g=replace(g,status='bought');notes.append(NAMES[i]+'が'+STORES[g.wish]+'に寄れた。')
  elif g.status!='pending' and g.pos==0:
   g=replace(g,exited=True,exit_t=s.t+s.walk_t+1);notes.append(NAMES[i]+'が帰りました。')
  result.append(g)
 return tuple(result),tuple(notes)
def legal(s):
 if s.phase=='walkout':return ('walk',)
 if s.phase!='play':return ()
 return tuple('close:'+str(i) for i,x in enumerate(s.opened) if x)+tuple('guide:'+str(i) for i,g in enumerate(s.guests) if not g.exited)+('wait',)
def step(s,a):
 if a not in legal(s):return s
 notes=[];guide=-1;n=s
 if a.startswith('close:'):
  i=int(a[-1]);opened=list(s.opened);opened[i]=False;n=replace(s,opened=tuple(opened),last=i,closed_self=s.closed_self|1<<i);notes.append(STORES[i]+'を閉めた。'+('脇道が通れる。' if s.variant=='paths' else '比較版では道は外周のまま。'))
 elif a.startswith('guide:'):guide=int(a[-1]);notes.append(NAMES[guide]+'を案内。希望の行先は変えません。')
 elif a=='wait':notes.append('店の灯りを、もう少し残した。' if any(s.opened) else '全員が帰った広場で、ひと休み。' if all(g.exited for g in s.guests) else '静かな広場で、帰り道を見届けた。')
 guests,events=move_guests(n,guide,a=='walk');notes.extend(events)
 if a=='walk':
  phase='done' if all(g.exited for g in guests) else 'walkout'
  return replace(n,guests=guests,walk_t=s.walk_t+1,phase=phase,notes=tuple(notes) or ('帰路を見届けています。',))
 sales=tuple(v+int(o) for v,o in zip(s.sales,n.opened));t=s.t+1;phase='play';cleanup=False
 if t==6:
  cleanup=any(n.opened)
  if cleanup:notes.append('残りの店は清掃係が閉店。自分で三店の依頼は未了。')
  n=replace(n,opened=(False,False,False));phase='done' if all(g.exited for g in guests) else 'walkout'
 return replace(n,t=t,guests=guests,sales=sales,cleanup=cleanup,phase=phase,notes=tuple(notes))
def forecast(s,a):return step(s,a)
def complete(s):return s.phase=='done' and s.closed_self==7 and all(g.exited for g in s.guests)

KEYS = {'RETURN','LEFT','RIGHT','UP','DOWN','TAB','SPACE','X','R','ESCAPE'}
class Controller:
    def __init__(self, path=None):
        self.s = new()
        self.mode = 0 if STAGE in (1,3) else 1
        self.target = 0
        self.previous = set()
        self.gate = False
        self.dialog = ''
        self.title = False
        self.help = False
        self.detail = False
        self.auto = False
        self.history = []
        self.log = []
        self.notice = ''

    def selection(self):
        if STAGE == 1:
            return self.target
        return 'close:' + str(self.target) if self.mode == 0 else 'guide:' + str(self.target) if self.mode == 1 else 'wait'

    def apply(self, action):
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            return 'ignored'
        self.history.append(old)
        return 'commit'

    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'
                self.s = new()
                self.history = []
                return 'reset'
            return 'ignored:dialog'
        if key == 'ESCAPE':
            self.dialog = 'quit'
            return 'confirm:quit'
        if key == 'R':
            self.dialog = 'retry'
            return 'confirm:retry'
        if getattr(self.s,'phase','play') == 'walkout':
            return self.apply('walk') if key == 'RETURN' else 'ignored:walkout'
        if getattr(self.s,'phase','play') == 'done':
            return 'ignored:done'
        if key in ('LEFT','UP','RIGHT','DOWN'):
            self.target = (self.target + (1 if key in ('RIGHT','DOWN') else -1)) % (6 if STAGE == 1 else 3)
            return 'select:target'
        if STAGE >= 2 and key == 'TAB':
            self.mode = (1 if self.mode == 2 else 2) if STAGE == 2 else (self.mode + 1) % 3
            return 'select:mode'
        if STAGE >= 2 and key == 'SPACE':
            self.mode = 2
            return 'select:wait'
        if key == 'RETURN':
            return self.apply(self.selection())
        return 'ignored:key'

import argparse
import json
from pathlib import Path
POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232))
COLORS = (10,12,14)
SHOP_LABELS = ('TEA','MASK','FIREWORK')

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())
    return event

def choose(ui, action):
    if STAGE == 1:
        while ui.target != action:
            tap(ui,'RIGHT')
    elif action == 'walk':
        pass
    elif action == 'wait':
        tap(ui,'SPACE')
    else:
        mode = 0 if action.startswith('close:') else 1
        while ui.mode != mode:
            tap(ui,'TAB')
        target = int(action[-1])
        while ui.target != target:
            tap(ui,'RIGHT')

def commit(ui, action):
    choose(ui, action)
    return tap(ui,'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        if scenario in ('case_b','case_c','fixed'):
            tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario])
        tap(ui,'RETURN')
    if scenario in ('case_b','case_c','fixed') and STAGE >= 4:
        commit(ui,'close:0' if scenario == 'fixed' else 'wait')
        return
    if scenario == 'finish_practice' and STAGE == 2:
        for _ in range(6):commit(ui,'wait')
        return
    if scenario == 'loaded' and STAGE == 5:
        tap(ui,'L')
        return
    if scenario == 'checkpoint':
        commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0')
    elif scenario == 'confirm':
        commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R')
    elif scenario == 'arrived' and STAGE == 1:
        for _ in range(3):commit(ui,0)
    elif scenario == 'guide' and STAGE >= 2:
        commit(ui,'guide:0')
    elif scenario == 'preview' and STAGE >= 2:
        choose(ui,'guide:0')
    elif scenario in ('closed','invalid_close') and STAGE >= 3:
        commit(ui,'close:2')
        if scenario == 'invalid_close':commit(ui,'close:2')
    elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3:
        if scenario in ('walkout','auto_help','auto_detail','auto_done'):
            # Explicit constructed boundary, not presented as a reachable natural campaign.
            ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2)))
            commit(ui,'wait')
        elif scenario == 'cleanup':
            for _ in range(6):commit(ui,'wait')
        else:
            for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]:
                commit(ui,action)
        if scenario in ('success','cleanup','saved','loaded'):
            while ui.s.phase == 'walkout':commit(ui,'walk')
        if scenario == 'undo':tap(ui,'X')
        if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L')
        if scenario.startswith('auto_'):
            tap(ui,'SPACE')
            if scenario == 'auto_help':tap(ui,'H')
            if scenario == 'auto_detail':tap(ui,'Z')
    elif scenario == 'detail' and STAGE >= 4:
        tap(ui,'Z')
    else:
        raise ValueError('scenario unavailable')

def parse_args(argv=None):
    p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094')
    p.add_argument('--headless-smoke',action='store_true')
    p.add_argument('--demo-checkpoint',action='store_true')
    scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview'])
    if STAGE==2:scenarios+=['finish_practice']
    if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout']
    if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed']
    if STAGE==5:scenarios+=['saved','loaded']
    p.add_argument('--scenario',choices=scenarios)
    p.add_argument('--screenshot',type=Path)
    p.add_argument('--state-json',type=Path)
    p.add_argument('--save-file',type=Path,default=Path('save_094.json'))
    a=p.parse_args(argv)
    if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke')
    if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario')
    for name,suffix in (('screenshot','.png'),('state_json','.json')):
        path=getattr(a,name)
        if path is None:continue
        path=path.resolve()
        if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory')
        setattr(a,name,path)
    a.save_file=a.save_file.resolve()
    if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory')
    if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ')
    if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file')
    return a

class App:
    def __init__(self,args):
        self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0
        if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2
        pyxel.init(600,420,title='094 FESTIVAL / 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 advance_auto(self):
        u=self.ui
        if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate):
            self.auto_clock+=1
            if self.auto_clock>=30:
                self.auto_clock=0;u.apply('walk')
        else:self.auto_clock=0

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

    def draw(self):
        u,s=self.ui,self.ui.s;pyxel.cls(1)
        pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10)
        pyxel.text(12,25,'TICK: '+str(s.t)+'  PHASE: '+getattr(s,'phase','walk').upper(),7)
        edge_list=edges(s) if STAGE>=3 else PERMANENT
        for a,b in edge_list:
            x,y=POINTS[a];xx,yy=POINTS[b]
            pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5)
        for i,(x,y) in enumerate(POINTS):
            pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6)
            # Solid label background prevents graph edges crossing text.
            pyxel.rect(x-20,y+21,62,10,1)
            pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7)
        guests=() if STAGE==1 else s.guests
        if STAGE==1:
            x,y=POINTS[s.pos];pyxel.circ(x,y,5,10)
        else:
            for i,g in enumerate(guests):
                x,y=POINTS[g.pos];x+=(i-1)*9
                if g.exited:pyxel.circb(x,y,4,COLORS[i])
                else:pyxel.circ(x,y,4,COLORS[i])
        pyxel.rect(345,48,243,228,2)
        if STAGE==1:
            pyxel.text(355,60,'POSITION: '+str(s.pos)+'  TARGET: '+str(u.target),10)
            pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7)
            pyxel.text(355,104,'Shortest path; small next node wins ties.',6)
        else:
            for i,g in enumerate(guests):
                y=60+i*48
                pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i])
                pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7)
                pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6)
        if STAGE>=3:
            pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11)
            pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7)
            pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7)
        phase=getattr(s,'phase','play')
        pyxel.rect(12,286,576,42,2)
        if phase=='play':
            selected=u.selection();preview=step(s,selected)
            pyxel.text(20,295,'SELECT: '+str(selected)+'  PREVIEW ONLY',10)
            if STAGE==1:message='NEXT POS '+str(preview.pos)
            elif preview==s:message='INVALID / no time or history change'
            else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+'  NEXT TICK '+str(preview.t)
            pyxel.text(20,312,message,6)
        elif phase=='walkout':
            pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10)
            pyxel.text(20,312,'ENTER: next step  SPACE: auto (stage 4+) / no more sales',7)
        else:
            achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests)
            pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11)
            pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7)
        pyxel.text(12,342,'Arrows: target  ENTER: commit one step  R: confirm restart',7)
        if STAGE>=2:pyxel.text(12,358,'TAB: mode  SPACE: select wait  Release ALL keys between presses',6)
        if STAGE>=4:pyxel.text(12,374,'X: Undo  Z: detail  H/P: help  M: menu  Title 1/2/3 + B',6)
        if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6)
        pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.title or u.help:
            pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10)
            pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10)
            pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7)
            pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7)
            pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7)
            pyxel.text(50,176,'Case '+s.case+'  Variant '+s.variant+' / 1,2,3 case; B variant',11)
            pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10)
            pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6)
        if u.detail:
            g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11)
            pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10)
            pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+'  POS '+str(g.pos)+'  '+g.status.upper(),7)
            pyxel.text(50,132,'GUIDED '+str(g.guided)+'  SHORTCUT BITS '+str(g.shortcuts),7)
            pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+'  LAST SELF-CLOSED '+str(s.last),7)
            pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6)
            pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10)
        if u.dialog:
            pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8)
            pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10)
            pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7)
            pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6)
        if u.notice:
            msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved'
            pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10)
        if self.args.headless_smoke and self.frame==self.capture_frame:
            if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'}
                with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2)
if __name__=='__main__':
    args=parse_args()
    import pyxel
    App(args)
STEP 04 / 05

最後の言葉と結果を読み、Undoする

起きたことにもとづく文・予告と本当の状態・比較と自動の再生。

04 は、保存以外の元のゲームのルールをそのまま使い、元のゲームの Controller の表紙、会話、Undo、確認、入力の待ちを加えます。表紙の 1/2/3 で状況 A/B/C、B で paths/fixed を選びます。fixed も、同じお客さん・希望・時間・達成の条件で、閉店しても脇道が加わらない、という1点だけが違います。比べたときに、全体の良い悪いの点数を作らず、売り上げ、買い物、帰り道を、別々に観察します。

goodbye は、帰り始めたその刻に実際に起きたことから文を作ります。買い物をしたか見送られたか、実際に通った脇道のビット shortcut、案内されたか guided、帰り始めたときの直前の閉店 last です。あとで別のお店を閉めても、もう帰った人のお別れの文 farewell は書き直しません。英数字(ASCII)の画面の Z は、見えている希望と記録を表示し、日本語の farewell の本文は、状態の JSON と元のゲームに残しています。日本語のお別れの全文を画面に描く機能は省いた、という違いがあります。

候補の PREVIEW は、step を呼んだ次の状態を読むだけで、本当の時刻・売り上げ・履歴は変わりません。X は、1刻前の State 全体を元に戻します。done や walkout からも戻れて、Undo すると auto=False(自動の再生が止まる)になります。Space は、play では「待つ」を選びますが、walkout では自動の再生の切り替えです。自動では30フレームごとに帰り道を1歩進め、説明・会話・確認・表紙・キーを待っている間は、時計を0に戻して止まります。ゲームの中の60分は6回の確定の操作で、現実に1時間待つ意味ではありません。同じ ZIP に入っている状況 A/B/C と、両方の比較版の、ルール上できる6刻の入力の列30,427通りでは、全員が6刻以内に帰り、walkout は起きません。この分かれ道はルールの機能として残し、--scenario walkout/auto_help/auto_detail/auto_done は、t5 の位置をはっきり作って試す、境目の検査です。ふつうに遊んで出てくる攻略の例ではありません。

キーは1回押すごとに、全部離してください。矢印・Tab・Space は選ぶだけで、Enter で確定します。エディターでコードを保存したら、Esc → 全部離す → Enter で終了して、同じコマンドで実行し直します。R はゲームの状態を最初に戻すだけで、Python のコードを読み直すわけではありません。

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

04_afterglow_undo.py / 注目する部分
def goodbye(s,i,g):
 first=NAMES[i]+'「'+(STORES[g.wish]+'に寄れた。' if g.status=='bought' else STORES[g.wish]+'はまた今度。')
 middle='脇道が帰り道になった。' if g.shortcuts else '広場の外周を歩いて帰るね。'
 end='案内ありがとう。' if g.guided else ('さっき'+STORES[s.last]+'を閉めたんだね。' if s.last>=0 else 'まだお祭りの灯りが見えるね。')
 return first+middle+end+'」'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_afterglow_undo.py
macOS / Linux
.venv/bin/python 04_afterglow_undo.py
画面で見ること表紙で Enter → 離す で始めます。Tab → 離す で guide:0、Enter → 離す で G0 は茶屋1で BOUGHT です。Z → 離す、Enter → 離す と押しても、会話中なので t1 のままです。Z → 離す で閉じ、X → 離す で t0・全員の最初の位置に戻ります。
  • お別れの文は、帰り始めたときに起きたこと
  • 会話・説明・確認の間は、世界が止まる
  • Undo すると、自動の再生も止まる
つまずきやすいところ会話中の X は、会話を閉じるだけで、Undo ではありません。Space も、場面によって意味が変わります。予告の next を今の state に代入すると、描くだけでゲームが進んでしまいます。
少し変えてみよう

SHORTCUTS=((0,4),(1,4),(1,5)) の最初の道だけを (0,5) に変えて、保存 → 終了 → 実行し直してみましょう。表紙で Enter → 離す、最初の close:0 のまま Enter → 離す と押します。G0 は5から0へ1本の道で帰り、SKIPPED、SHORTCUT BITS=1、exit_t=1 です。Z → 離す で記録を読み、Z → 離す で閉じ、X → 離す で t0 に戻します。python exercises/04_shortcut.py で、全部の操作を実行できます。最後に (0,4) に戻します。

04_afterglow_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(396行)
04_afterglow_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 = 4

"""Simultaneous guests follow their own wishes; closing stalls opens public paths."""
from dataclasses import dataclass,replace,asdict,fields
from collections import deque
import hashlib,json
GAME_ID='094';VERSION='094-1.0'
NAMES=('ユイ','タク','エン');STORES=('茶店','面屋','花火屋');PLACES=('出口','茶前','花火前','橋','面前','舞台');SHOP_POS=(1,4,2)
PERMANENT=((0,3),(3,4),(4,5),(5,2),(2,1),(1,0));SHORTCUTS=((0,4),(1,4),(1,5))
TITLES={'A':'あと一杯のお茶','B':'舞台の帰り','C':'橋の向こうの三人'}
INITIAL={'A':((5,0),(3,1),(2,2)),'B':((5,1),(5,0),(4,2)),'C':((3,2),(4,0),(5,1))}
@dataclass(frozen=True)
class Guest:
 pos:int;wish:int;status:str='pending';exited:bool=False;guided:bool=False;shortcuts:int=0;exit_t:int=-1;farewell:str=''
@dataclass(frozen=True)
class State:
 case:str='A';variant:str='paths';t:int=0;opened:tuple=(True,True,True);guests:tuple=();sales:tuple=(0,0,0);last:int=-1;closed_self:int=0;cleanup:bool=False;phase:str='play';walk_t:int=0;notes:tuple=('終わるお祭りに、どの道を残そう。',)
def new(case='A',variant='paths'):
 if case not in INITIAL or variant not in ('paths','fixed'):raise ValueError('setup')
 return State(case=case,variant=variant,guests=tuple(Guest(p,w) for p,w in INITIAL[case]))
def edges(s):return PERMANENT+tuple(e for i,e in enumerate(SHORTCUTS) if not s.opened[i] and s.variant=='paths')
def route(s,start,target):
 if start==target:return ()
 graph={i:[] for i in range(6)}
 for a,b in edges(s):graph[a].append(b);graph[b].append(a)
 dist={target:0};q=deque([target])
 while q:
  p=q.popleft()
  for n in graph[p]:
   if n not in dist:dist[n]=dist[p]+1;q.append(n)
 result=[];p=start
 while p!=target:p=min(n for n in graph[p] if dist[n]==dist[p]-1);result.append(p)
 return tuple(result)
def goal(g):return SHOP_POS[g.wish] if g.status=='pending' else 0

def goodbye(s,i,g):
 first=NAMES[i]+'「'+(STORES[g.wish]+'に寄れた。' if g.status=='bought' else STORES[g.wish]+'はまた今度。')
 middle='脇道が帰り道になった。' if g.shortcuts else '広場の外周を歩いて帰るね。'
 end='案内ありがとう。' if g.guided else ('さっき'+STORES[s.last]+'を閉めたんだね。' if s.last>=0 else 'まだお祭りの灯りが見えるね。')
 return first+middle+end+'」'
def move_guests(s,guide=-1,walkout=False):
 result=[];notes=[]
 for i,old in enumerate(s.guests):
  if old.exited:result.append(old);continue
  g=replace(old,guided=old.guided or guide==i)
  if g.status=='pending' and not s.opened[g.wish]:g=replace(g,status='skipped');notes.append(NAMES[i]+'の'+STORES[g.wish]+'は今回は見送り。')
  target=goal(g);path=route(s,g.pos,target);steps=path[:2 if guide==i else 1];prev=g.pos;bits=g.shortcuts
  for n in steps:
   for j,e in enumerate(SHORTCUTS):
    if s.variant=='paths' and not s.opened[j] and set((prev,n))==set(e):bits|=1<<j
   prev=n
  g=replace(g,pos=prev,shortcuts=bits)
  if g.status=='pending' and g.pos==target:g=replace(g,status='bought');notes.append(NAMES[i]+'が'+STORES[g.wish]+'に寄れた。')
  elif g.status!='pending' and g.pos==0:
   g=replace(g,exited=True,exit_t=s.t+s.walk_t+1);g=replace(g,farewell=goodbye(s,i,g));notes.append(NAMES[i]+'が帰りました。')
  result.append(g)
 return tuple(result),tuple(notes)
def legal(s):
 if s.phase=='walkout':return ('walk',)
 if s.phase!='play':return ()
 return tuple('close:'+str(i) for i,x in enumerate(s.opened) if x)+tuple('guide:'+str(i) for i,g in enumerate(s.guests) if not g.exited)+('wait',)
def step(s,a):
 if a not in legal(s):return s
 notes=[];guide=-1;n=s
 if a.startswith('close:'):
  i=int(a[-1]);opened=list(s.opened);opened[i]=False;n=replace(s,opened=tuple(opened),last=i,closed_self=s.closed_self|1<<i);notes.append(STORES[i]+'を閉めた。'+('脇道が通れる。' if s.variant=='paths' else '比較版では道は外周のまま。'))
 elif a.startswith('guide:'):guide=int(a[-1]);notes.append(NAMES[guide]+'を案内。希望の行先は変えません。')
 elif a=='wait':notes.append('店の灯りを、もう少し残した。' if any(s.opened) else '全員が帰った広場で、ひと休み。' if all(g.exited for g in s.guests) else '静かな広場で、帰り道を見届けた。')
 guests,events=move_guests(n,guide,a=='walk');notes.extend(events)
 if a=='walk':
  phase='done' if all(g.exited for g in guests) else 'walkout'
  return replace(n,guests=guests,walk_t=s.walk_t+1,phase=phase,notes=tuple(notes) or ('帰路を見届けています。',))
 sales=tuple(v+int(o) for v,o in zip(s.sales,n.opened));t=s.t+1;phase='play';cleanup=False
 if t==6:
  cleanup=any(n.opened)
  if cleanup:notes.append('残りの店は清掃係が閉店。自分で三店の依頼は未了。')
  n=replace(n,opened=(False,False,False));phase='done' if all(g.exited for g in guests) else 'walkout'
 return replace(n,t=t,guests=guests,sales=sales,cleanup=cleanup,phase=phase,notes=tuple(notes))
def forecast(s,a):return step(s,a)
def complete(s):return s.phase=='done' and s.closed_self==7 and all(g.exited for g in s.guests)

KEYS={'SPACE','RETURN','Z','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','TAB','1','2','3','B','S','L','F12','ESCAPE'}
class Controller:
 def __init__(self,path='save_094.json'):
  self.s=new();self.title=True;self.help=False;self.detail=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.mode=0;self.target=0;self.auto=False
 def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.mode=0;self.target=0;self.auto=False;self.detail=False
 def selection(self):return 'close:'+str(self.target) if self.mode==0 else 'guide:'+str(self.target) if self.mode==1 else 'wait'
 def apply(self,a):
  if a=='undo':
   if not self.history:return 'ignored:undo'
   self.s=self.history.pop();self.log.append(a);self.auto=False;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=='retry':self.reset();return 'reset'
    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.detail:
   if k in ('Z','X'):self.detail=False;return 'detail:close'
   return 'ignored:detail'
  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=='paths' else 'paths');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=='R':self.dialog='retry';return 'confirm:retry'
  if k=='X':return self.apply('undo')
  if k=='Z':self.detail=True;return 'detail:open'
  if s:=self.s:
   if s.phase=='walkout':
    if k=='SPACE':self.auto=not self.auto;return 'auto'
    if k=='RETURN':return self.apply('walk')
    return 'ignored:walkout'
   if s.phase=='done':return 'ignored:done'
  if k=='TAB':self.mode=(self.mode+1)%3;return 'select:mode'
  if k in ('UP','LEFT'):self.target=(self.target-1)%3;return 'select:target'
  if k in ('DOWN','RIGHT'):self.target=(self.target+1)%3;return 'select:target'
  if k=='SPACE':self.mode=2;return 'select:wait'
  if k=='RETURN':return self.apply(self.selection())
  return 'ignored:key'

import argparse
import json
from pathlib import Path
POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232))
COLORS = (10,12,14)
SHOP_LABELS = ('TEA','MASK','FIREWORK')

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())
    return event

def choose(ui, action):
    if STAGE == 1:
        while ui.target != action:
            tap(ui,'RIGHT')
    elif action == 'walk':
        pass
    elif action == 'wait':
        tap(ui,'SPACE')
    else:
        mode = 0 if action.startswith('close:') else 1
        while ui.mode != mode:
            tap(ui,'TAB')
        target = int(action[-1])
        while ui.target != target:
            tap(ui,'RIGHT')

def commit(ui, action):
    choose(ui, action)
    return tap(ui,'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        if scenario in ('case_b','case_c','fixed'):
            tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario])
        tap(ui,'RETURN')
    if scenario in ('case_b','case_c','fixed') and STAGE >= 4:
        commit(ui,'close:0' if scenario == 'fixed' else 'wait')
        return
    if scenario == 'finish_practice' and STAGE == 2:
        for _ in range(6):commit(ui,'wait')
        return
    if scenario == 'loaded' and STAGE == 5:
        tap(ui,'L')
        return
    if scenario == 'checkpoint':
        commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0')
    elif scenario == 'confirm':
        commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R')
    elif scenario == 'arrived' and STAGE == 1:
        for _ in range(3):commit(ui,0)
    elif scenario == 'guide' and STAGE >= 2:
        commit(ui,'guide:0')
    elif scenario == 'preview' and STAGE >= 2:
        choose(ui,'guide:0')
    elif scenario in ('closed','invalid_close') and STAGE >= 3:
        commit(ui,'close:2')
        if scenario == 'invalid_close':commit(ui,'close:2')
    elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3:
        if scenario in ('walkout','auto_help','auto_detail','auto_done'):
            # Explicit constructed boundary, not presented as a reachable natural campaign.
            ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2)))
            commit(ui,'wait')
        elif scenario == 'cleanup':
            for _ in range(6):commit(ui,'wait')
        else:
            for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]:
                commit(ui,action)
        if scenario in ('success','cleanup','saved','loaded'):
            while ui.s.phase == 'walkout':commit(ui,'walk')
        if scenario == 'undo':tap(ui,'X')
        if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L')
        if scenario.startswith('auto_'):
            tap(ui,'SPACE')
            if scenario == 'auto_help':tap(ui,'H')
            if scenario == 'auto_detail':tap(ui,'Z')
    elif scenario == 'detail' and STAGE >= 4:
        tap(ui,'Z')
    else:
        raise ValueError('scenario unavailable')

def parse_args(argv=None):
    p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094')
    p.add_argument('--headless-smoke',action='store_true')
    p.add_argument('--demo-checkpoint',action='store_true')
    scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview'])
    if STAGE==2:scenarios+=['finish_practice']
    if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout']
    if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed']
    if STAGE==5:scenarios+=['saved','loaded']
    p.add_argument('--scenario',choices=scenarios)
    p.add_argument('--screenshot',type=Path)
    p.add_argument('--state-json',type=Path)
    p.add_argument('--save-file',type=Path,default=Path('save_094.json'))
    a=p.parse_args(argv)
    if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke')
    if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario')
    for name,suffix in (('screenshot','.png'),('state_json','.json')):
        path=getattr(a,name)
        if path is None:continue
        path=path.resolve()
        if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory')
        setattr(a,name,path)
    a.save_file=a.save_file.resolve()
    if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory')
    if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ')
    if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file')
    return a

class App:
    def __init__(self,args):
        self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0
        if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2
        pyxel.init(600,420,title='094 FESTIVAL / 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 advance_auto(self):
        u=self.ui
        if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate):
            self.auto_clock+=1
            if self.auto_clock>=30:
                self.auto_clock=0;u.apply('walk')
        else:self.auto_clock=0

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

    def draw(self):
        u,s=self.ui,self.ui.s;pyxel.cls(1)
        pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10)
        pyxel.text(12,25,'TICK: '+str(s.t)+'  PHASE: '+getattr(s,'phase','walk').upper(),7)
        edge_list=edges(s) if STAGE>=3 else PERMANENT
        for a,b in edge_list:
            x,y=POINTS[a];xx,yy=POINTS[b]
            pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5)
        for i,(x,y) in enumerate(POINTS):
            pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6)
            # Solid label background prevents graph edges crossing text.
            pyxel.rect(x-20,y+21,62,10,1)
            pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7)
        guests=() if STAGE==1 else s.guests
        if STAGE==1:
            x,y=POINTS[s.pos];pyxel.circ(x,y,5,10)
        else:
            for i,g in enumerate(guests):
                x,y=POINTS[g.pos];x+=(i-1)*9
                if g.exited:pyxel.circb(x,y,4,COLORS[i])
                else:pyxel.circ(x,y,4,COLORS[i])
        pyxel.rect(345,48,243,228,2)
        if STAGE==1:
            pyxel.text(355,60,'POSITION: '+str(s.pos)+'  TARGET: '+str(u.target),10)
            pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7)
            pyxel.text(355,104,'Shortest path; small next node wins ties.',6)
        else:
            for i,g in enumerate(guests):
                y=60+i*48
                pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i])
                pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7)
                pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6)
        if STAGE>=3:
            pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11)
            pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7)
            pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7)
        phase=getattr(s,'phase','play')
        pyxel.rect(12,286,576,42,2)
        if phase=='play':
            selected=u.selection();preview=step(s,selected)
            pyxel.text(20,295,'SELECT: '+str(selected)+'  PREVIEW ONLY',10)
            if STAGE==1:message='NEXT POS '+str(preview.pos)
            elif preview==s:message='INVALID / no time or history change'
            else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+'  NEXT TICK '+str(preview.t)
            pyxel.text(20,312,message,6)
        elif phase=='walkout':
            pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10)
            pyxel.text(20,312,'ENTER: next step  SPACE: auto (stage 4+) / no more sales',7)
        else:
            achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests)
            pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11)
            pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7)
        pyxel.text(12,342,'Arrows: target  ENTER: commit one step  R: confirm restart',7)
        if STAGE>=2:pyxel.text(12,358,'TAB: mode  SPACE: select wait  Release ALL keys between presses',6)
        if STAGE>=4:pyxel.text(12,374,'X: Undo  Z: detail  H/P: help  M: menu  Title 1/2/3 + B',6)
        if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6)
        pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.title or u.help:
            pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10)
            pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10)
            pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7)
            pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7)
            pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7)
            pyxel.text(50,176,'Case '+s.case+'  Variant '+s.variant+' / 1,2,3 case; B variant',11)
            pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10)
            pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6)
        if u.detail:
            g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11)
            pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10)
            pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+'  POS '+str(g.pos)+'  '+g.status.upper(),7)
            pyxel.text(50,132,'GUIDED '+str(g.guided)+'  SHORTCUT BITS '+str(g.shortcuts),7)
            pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+'  LAST SELF-CLOSED '+str(s.last),7)
            pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6)
            pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10)
        if u.dialog:
            pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8)
            pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10)
            pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7)
            pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6)
        if u.notice:
            msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved'
            pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10)
        if self.args.headless_smoke and self.frame==self.capture_frame:
            if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'}
                with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2)
if __name__=='__main__':
    args=parse_args()
    import pyxel
    App(args)
STEP 05 / 05

夕方の全状態と履歴を保存する

確かめてから読みこむ UTF-8 での保存・起動し直したあとの Undo。

05 には、元のゲームの model.py の定義とデータ全部、Controller の定義全部、storage の save/load の定義が入っています。import だけを1つのファイル用につなぎ直し、storage という名前のまとまりで、同じ関数を見せています。S で保存、L で読みこみです。093 の三夜とは違い、094 は、play/walkout/done のどの場面でも保存できます。ただし、表紙・説明・会話・確認の画面では、それぞれの入力の処理が先に働くので、ふつうのゲームの画面に戻してから使います。

いつもの保存先は、起動したときの作業フォルダーの save_094.json です。--save-file で指定した相対パスも、Pyxel を始める前に絶対パスに直します。保存には、作品の ID・版・case・variant・State・それぞれの Guest・Undo の履歴・意味のある入力を含みます。一時的なファイルに UTF-8 をはっきり指定して書き、flush/fsync のあとで置きかえます。ふつうの S は、すでにあるセーブを更新します。確認用の画像や状態の JSON は、すでにあるファイルを上書きしません。

読みこむときは、項目のそろい方、型、数値の範囲、phase と t のつじつま、帰り始めの条件、開いているお店と closed_self の矛盾を調べます。全部確かめてから、状態・履歴・記録を置きかえます。失敗しても、元のファイルと今の状態は残ります。元のゲームどおりの形式の確認で、すべての履歴が正しい入力の列から生まれたことを証明するものではありません。L と X は、自動の再生を止めます。

エディターでのコードの保存と、S でのゲームの保存は別のものです。コードを変えなくても、S → 離す、Esc → 離す → Enter で終了し、同じコマンドで実行し直して、表紙で Enter → 離す、L → 離す で続きから遊べます。X で、保存する前の1刻に戻れることも確かめます。

キーは1回押すごとに、全部離してください。矢印・Tab・Space は選ぶだけで、Enter で確定します。エディターでコードを保存したら、Esc → 全部離す → Enter で終了して、同じコマンドで実行し直します。R はゲームの状態を最初に戻すだけで、Python のコードを読み直すわけではありません。

元のゲームの日本語の文字、飾り、F12 での画像の保存、コードと出来事の記録は移していません。音はありません。次に元のゲームの app.py の wrap/record/flush_events を読むと、日本語のお別れの文を画面に戻せます。

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

05_save_evening.py / 注目する部分
  if k in ('S','L'):
   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.auto=False;self.notice='この夕方を読み込みました。';return 'load'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_save_evening.py
macOS / Linux
.venv/bin/python 05_save_evening.py
画面で見ること表紙で Enter → 離す、Tab → 離す → Enter → 離す で、guide:0 の t1 です。S → 離す で SAVE OK。終了 → 実行し直す → 表紙で Enter → 離す → L → 離す で、同じ t1 と履歴に戻ります。X → 離す で最初の状態に戻ります。
  • プレイの途中でも保存できる
  • Guest と履歴を確かめてから元に戻す
  • 起動し直したあとも Undo できる
つまずきやすいところ辞書から Guest を作り直さずに残すと、g.pos が読めません。open や read_text で encoding を省かないでください。L を押しただけで表紙が閉じる仕様ではありません。
少し変えてみよう

Controller.__init__ の self.path=path;self.mode=0;self.target=0;self.auto=False を self.path=path;self.mode=2;self.target=0;self.auto=False に変えてみましょう。保存 → 終了 → 実行し直します。表紙で Enter → 離す のあと、さらに Enter → 離す で wait(待つ)が確定し、t1、sales=(1,1,1) です。S → 離す、X → 離す で t0、L → 離す で t1 と履歴が戻ります。python exercises/05_saved_selection.py は、一時的な保存先で、書き換え・全部のキー・保存と復元を実行します。最後に mode=0 に戻します。

05_save_evening.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(452行)
05_save_evening.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

"""Simultaneous guests follow their own wishes; closing stalls opens public paths."""
from dataclasses import dataclass,replace,asdict,fields
from collections import deque
import hashlib,json
GAME_ID='094';VERSION='094-1.0'
NAMES=('ユイ','タク','エン');STORES=('茶店','面屋','花火屋');PLACES=('出口','茶前','花火前','橋','面前','舞台');SHOP_POS=(1,4,2)
PERMANENT=((0,3),(3,4),(4,5),(5,2),(2,1),(1,0));SHORTCUTS=((0,4),(1,4),(1,5))
TITLES={'A':'あと一杯のお茶','B':'舞台の帰り','C':'橋の向こうの三人'}
INITIAL={'A':((5,0),(3,1),(2,2)),'B':((5,1),(5,0),(4,2)),'C':((3,2),(4,0),(5,1))}
@dataclass(frozen=True)
class Guest:
 pos:int;wish:int;status:str='pending';exited:bool=False;guided:bool=False;shortcuts:int=0;exit_t:int=-1;farewell:str=''
@dataclass(frozen=True)
class State:
 case:str='A';variant:str='paths';t:int=0;opened:tuple=(True,True,True);guests:tuple=();sales:tuple=(0,0,0);last:int=-1;closed_self:int=0;cleanup:bool=False;phase:str='play';walk_t:int=0;notes:tuple=('終わるお祭りに、どの道を残そう。',)
def new(case='A',variant='paths'):
 if case not in INITIAL or variant not in ('paths','fixed'):raise ValueError('setup')
 return State(case=case,variant=variant,guests=tuple(Guest(p,w) for p,w in INITIAL[case]))
def edges(s):return PERMANENT+tuple(e for i,e in enumerate(SHORTCUTS) if not s.opened[i] and s.variant=='paths')
def route(s,start,target):
 if start==target:return ()
 graph={i:[] for i in range(6)}
 for a,b in edges(s):graph[a].append(b);graph[b].append(a)
 dist={target:0};q=deque([target])
 while q:
  p=q.popleft()
  for n in graph[p]:
   if n not in dist:dist[n]=dist[p]+1;q.append(n)
 result=[];p=start
 while p!=target:p=min(n for n in graph[p] if dist[n]==dist[p]-1);result.append(p)
 return tuple(result)
def goal(g):return SHOP_POS[g.wish] if g.status=='pending' else 0

def goodbye(s,i,g):
 first=NAMES[i]+'「'+(STORES[g.wish]+'に寄れた。' if g.status=='bought' else STORES[g.wish]+'はまた今度。')
 middle='脇道が帰り道になった。' if g.shortcuts else '広場の外周を歩いて帰るね。'
 end='案内ありがとう。' if g.guided else ('さっき'+STORES[s.last]+'を閉めたんだね。' if s.last>=0 else 'まだお祭りの灯りが見えるね。')
 return first+middle+end+'」'
def move_guests(s,guide=-1,walkout=False):
 result=[];notes=[]
 for i,old in enumerate(s.guests):
  if old.exited:result.append(old);continue
  g=replace(old,guided=old.guided or guide==i)
  if g.status=='pending' and not s.opened[g.wish]:g=replace(g,status='skipped');notes.append(NAMES[i]+'の'+STORES[g.wish]+'は今回は見送り。')
  target=goal(g);path=route(s,g.pos,target);steps=path[:2 if guide==i else 1];prev=g.pos;bits=g.shortcuts
  for n in steps:
   for j,e in enumerate(SHORTCUTS):
    if s.variant=='paths' and not s.opened[j] and set((prev,n))==set(e):bits|=1<<j
   prev=n
  g=replace(g,pos=prev,shortcuts=bits)
  if g.status=='pending' and g.pos==target:g=replace(g,status='bought');notes.append(NAMES[i]+'が'+STORES[g.wish]+'に寄れた。')
  elif g.status!='pending' and g.pos==0:
   g=replace(g,exited=True,exit_t=s.t+s.walk_t+1);g=replace(g,farewell=goodbye(s,i,g));notes.append(NAMES[i]+'が帰りました。')
  result.append(g)
 return tuple(result),tuple(notes)
def legal(s):
 if s.phase=='walkout':return ('walk',)
 if s.phase!='play':return ()
 return tuple('close:'+str(i) for i,x in enumerate(s.opened) if x)+tuple('guide:'+str(i) for i,g in enumerate(s.guests) if not g.exited)+('wait',)
def step(s,a):
 if a not in legal(s):return s
 notes=[];guide=-1;n=s
 if a.startswith('close:'):
  i=int(a[-1]);opened=list(s.opened);opened[i]=False;n=replace(s,opened=tuple(opened),last=i,closed_self=s.closed_self|1<<i);notes.append(STORES[i]+'を閉めた。'+('脇道が通れる。' if s.variant=='paths' else '比較版では道は外周のまま。'))
 elif a.startswith('guide:'):guide=int(a[-1]);notes.append(NAMES[guide]+'を案内。希望の行先は変えません。')
 elif a=='wait':notes.append('店の灯りを、もう少し残した。' if any(s.opened) else '全員が帰った広場で、ひと休み。' if all(g.exited for g in s.guests) else '静かな広場で、帰り道を見届けた。')
 guests,events=move_guests(n,guide,a=='walk');notes.extend(events)
 if a=='walk':
  phase='done' if all(g.exited for g in guests) else 'walkout'
  return replace(n,guests=guests,walk_t=s.walk_t+1,phase=phase,notes=tuple(notes) or ('帰路を見届けています。',))
 sales=tuple(v+int(o) for v,o in zip(s.sales,n.opened));t=s.t+1;phase='play';cleanup=False
 if t==6:
  cleanup=any(n.opened)
  if cleanup:notes.append('残りの店は清掃係が閉店。自分で三店の依頼は未了。')
  n=replace(n,opened=(False,False,False));phase='done' if all(g.exited for g in guests) else 'walkout'
 return replace(n,t=t,guests=guests,sales=sales,cleanup=cleanup,phase=phase,notes=tuple(notes))
def forecast(s,a):return step(s,a)
def complete(s):return s.phase=='done' and s.closed_self==7 and all(g.exited for g in s.guests)
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 ('opened','sales','notes'):
  if not isinstance(d[k],list):raise ValueError(k)
  d[k]=tuple(d[k])
 if not isinstance(d['guests'],list) or len(d['guests'])!=3:raise ValueError('guests')
 gs=[]
 for g in d['guests']:
  if not isinstance(g,dict) or set(g)!={f.name for f in fields(Guest)}:raise ValueError('guest fields')
  g=Guest(**g)
  for k,lo,hi in (('pos',0,5),('wish',0,2),('shortcuts',0,7),('exit_t',-1,12)):
   if type(getattr(g,k)) is not int or not lo<=getattr(g,k)<=hi:raise ValueError(k)
  if type(g.exited) is not bool or type(g.guided) is not bool or g.status not in ('pending','bought','skipped') or not isinstance(g.farewell,str) or len(g.farewell)>200:raise ValueError('guest value')
  if g.exited and (g.pos!=0 or g.status=='pending' or g.exit_t<1):raise ValueError('exit')
  gs.append(g)
 d['guests']=tuple(gs);s=State(**d)
 if s.case not in INITIAL or s.variant not in ('paths','fixed') or s.phase not in ('play','walkout','done'):raise ValueError('enum')
 for k,lo,hi in (('t',0,6),('last',-1,2),('closed_self',0,7),('walk_t',0,6)):
  if type(getattr(s,k)) is not int or not lo<=getattr(s,k)<=hi:raise ValueError(k)
 if len(s.opened)!=3 or any(type(x) is not bool for x in s.opened) or len(s.sales)!=3 or any(type(x) is not int or not 0<=x<=6 for x in s.sales) or type(s.cleanup) is not bool:raise ValueError('stores')
 if not 1<=len(s.notes)<=10 or any(not isinstance(x,str) or len(x)>200 for x in s.notes):raise ValueError('notes')
 if (s.phase=='play')!=(s.t<6) or s.phase=='done' and not all(g.exited for g in s.guests):raise ValueError('phase')
 if any(s.opened[i] and s.closed_self>>i&1 for i in range(3)):raise ValueError('closed')
 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','M','UP','DOWN','LEFT','RIGHT','TAB','1','2','3','B','S','L','F12','ESCAPE'}
class Controller:
 def __init__(self,path='save_094.json'):
  self.s=new();self.title=True;self.help=False;self.detail=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.mode=0;self.target=0;self.auto=False
 def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.mode=0;self.target=0;self.auto=False;self.detail=False
 def selection(self):return 'close:'+str(self.target) if self.mode==0 else 'guide:'+str(self.target) if self.mode==1 else 'wait'
 def apply(self,a):
  if a=='undo':
   if not self.history:return 'ignored:undo'
   self.s=self.history.pop();self.log.append(a);self.auto=False;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=='retry':self.reset();return 'reset'
    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.detail:
   if k in ('Z','X'):self.detail=False;return 'detail:close'
   return 'ignored:detail'
  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=='paths' else 'paths');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=='R':self.dialog='retry';return 'confirm:retry'
  if k=='X':return self.apply('undo')
  if k in ('S','L'):
   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.auto=False;self.notice='この夕方を読み込みました。';return 'load'
   except (ValueError,KeyError,TypeError,OSError):self.notice='保存を読めません。元ファイルは残しました。Rで新規開始。';return 'save-error'
  if k=='Z':self.detail=True;return 'detail:open'
  if s:=self.s:
   if s.phase=='walkout':
    if k=='SPACE':self.auto=not self.auto;return 'auto'
    if k=='RETURN':return self.apply('walk')
    return 'ignored:walkout'
   if s.phase=='done':return 'ignored:done'
  if k=='TAB':self.mode=(self.mode+1)%3;return 'select:mode'
  if k in ('UP','LEFT'):self.target=(self.target-1)%3;return 'select:target'
  if k in ('DOWN','RIGHT'):self.target=(self.target+1)%3;return 'select:target'
  if k=='SPACE':self.mode=2;return 'select:wait'
  if k=='RETURN':return self.apply(self.selection())
  return 'ignored:key'

import argparse
import json
from pathlib import Path
POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232))
COLORS = (10,12,14)
SHOP_LABELS = ('TEA','MASK','FIREWORK')

def tap(ui, key):
    event = ui.feed({key})
    ui.feed(set())
    return event

def choose(ui, action):
    if STAGE == 1:
        while ui.target != action:
            tap(ui,'RIGHT')
    elif action == 'walk':
        pass
    elif action == 'wait':
        tap(ui,'SPACE')
    else:
        mode = 0 if action.startswith('close:') else 1
        while ui.mode != mode:
            tap(ui,'TAB')
        target = int(action[-1])
        while ui.target != target:
            tap(ui,'RIGHT')

def commit(ui, action):
    choose(ui, action)
    return tap(ui,'RETURN')

def demo(ui, scenario):
    if scenario == 'initial':
        return
    if ui.title:
        if scenario in ('case_b','case_c','fixed'):
            tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario])
        tap(ui,'RETURN')
    if scenario in ('case_b','case_c','fixed') and STAGE >= 4:
        commit(ui,'close:0' if scenario == 'fixed' else 'wait')
        return
    if scenario == 'finish_practice' and STAGE == 2:
        for _ in range(6):commit(ui,'wait')
        return
    if scenario == 'loaded' and STAGE == 5:
        tap(ui,'L')
        return
    if scenario == 'checkpoint':
        commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0')
    elif scenario == 'confirm':
        commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R')
    elif scenario == 'arrived' and STAGE == 1:
        for _ in range(3):commit(ui,0)
    elif scenario == 'guide' and STAGE >= 2:
        commit(ui,'guide:0')
    elif scenario == 'preview' and STAGE >= 2:
        choose(ui,'guide:0')
    elif scenario in ('closed','invalid_close') and STAGE >= 3:
        commit(ui,'close:2')
        if scenario == 'invalid_close':commit(ui,'close:2')
    elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3:
        if scenario in ('walkout','auto_help','auto_detail','auto_done'):
            # Explicit constructed boundary, not presented as a reachable natural campaign.
            ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2)))
            commit(ui,'wait')
        elif scenario == 'cleanup':
            for _ in range(6):commit(ui,'wait')
        else:
            for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]:
                commit(ui,action)
        if scenario in ('success','cleanup','saved','loaded'):
            while ui.s.phase == 'walkout':commit(ui,'walk')
        if scenario == 'undo':tap(ui,'X')
        if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L')
        if scenario.startswith('auto_'):
            tap(ui,'SPACE')
            if scenario == 'auto_help':tap(ui,'H')
            if scenario == 'auto_detail':tap(ui,'Z')
    elif scenario == 'detail' and STAGE >= 4:
        tap(ui,'Z')
    else:
        raise ValueError('scenario unavailable')

def parse_args(argv=None):
    p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094')
    p.add_argument('--headless-smoke',action='store_true')
    p.add_argument('--demo-checkpoint',action='store_true')
    scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview'])
    if STAGE==2:scenarios+=['finish_practice']
    if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout']
    if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed']
    if STAGE==5:scenarios+=['saved','loaded']
    p.add_argument('--scenario',choices=scenarios)
    p.add_argument('--screenshot',type=Path)
    p.add_argument('--state-json',type=Path)
    p.add_argument('--save-file',type=Path,default=Path('save_094.json'))
    a=p.parse_args(argv)
    if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke')
    if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario')
    for name,suffix in (('screenshot','.png'),('state_json','.json')):
        path=getattr(a,name)
        if path is None:continue
        path=path.resolve()
        if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory')
        setattr(a,name,path)
    a.save_file=a.save_file.resolve()
    if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory')
    if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ')
    if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file')
    return a

class App:
    def __init__(self,args):
        self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0
        if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial'))
        self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2
        pyxel.init(600,420,title='094 FESTIVAL / 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 advance_auto(self):
        u=self.ui
        if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate):
            self.auto_clock+=1
            if self.auto_clock>=30:
                self.auto_clock=0;u.apply('walk')
        else:self.auto_clock=0

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

    def draw(self):
        u,s=self.ui,self.ui.s;pyxel.cls(1)
        pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10)
        pyxel.text(12,25,'TICK: '+str(s.t)+'  PHASE: '+getattr(s,'phase','walk').upper(),7)
        edge_list=edges(s) if STAGE>=3 else PERMANENT
        for a,b in edge_list:
            x,y=POINTS[a];xx,yy=POINTS[b]
            pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5)
        for i,(x,y) in enumerate(POINTS):
            pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6)
            # Solid label background prevents graph edges crossing text.
            pyxel.rect(x-20,y+21,62,10,1)
            pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7)
        guests=() if STAGE==1 else s.guests
        if STAGE==1:
            x,y=POINTS[s.pos];pyxel.circ(x,y,5,10)
        else:
            for i,g in enumerate(guests):
                x,y=POINTS[g.pos];x+=(i-1)*9
                if g.exited:pyxel.circb(x,y,4,COLORS[i])
                else:pyxel.circ(x,y,4,COLORS[i])
        pyxel.rect(345,48,243,228,2)
        if STAGE==1:
            pyxel.text(355,60,'POSITION: '+str(s.pos)+'  TARGET: '+str(u.target),10)
            pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7)
            pyxel.text(355,104,'Shortest path; small next node wins ties.',6)
        else:
            for i,g in enumerate(guests):
                y=60+i*48
                pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i])
                pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7)
                pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6)
        if STAGE>=3:
            pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11)
            pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7)
            pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7)
        phase=getattr(s,'phase','play')
        pyxel.rect(12,286,576,42,2)
        if phase=='play':
            selected=u.selection();preview=step(s,selected)
            pyxel.text(20,295,'SELECT: '+str(selected)+'  PREVIEW ONLY',10)
            if STAGE==1:message='NEXT POS '+str(preview.pos)
            elif preview==s:message='INVALID / no time or history change'
            else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+'  NEXT TICK '+str(preview.t)
            pyxel.text(20,312,message,6)
        elif phase=='walkout':
            pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10)
            pyxel.text(20,312,'ENTER: next step  SPACE: auto (stage 4+) / no more sales',7)
        else:
            achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests)
            pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11)
            pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7)
        pyxel.text(12,342,'Arrows: target  ENTER: commit one step  R: confirm restart',7)
        if STAGE>=2:pyxel.text(12,358,'TAB: mode  SPACE: select wait  Release ALL keys between presses',6)
        if STAGE>=4:pyxel.text(12,374,'X: Undo  Z: detail  H/P: help  M: menu  Title 1/2/3 + B',6)
        if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6)
        pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7)
        if u.title or u.help:
            pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10)
            pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10)
            pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7)
            pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7)
            pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7)
            pyxel.text(50,176,'Case '+s.case+'  Variant '+s.variant+' / 1,2,3 case; B variant',11)
            pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10)
            pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6)
        if u.detail:
            g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11)
            pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10)
            pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+'  POS '+str(g.pos)+'  '+g.status.upper(),7)
            pyxel.text(50,132,'GUIDED '+str(g.guided)+'  SHORTCUT BITS '+str(g.shortcuts),7)
            pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+'  LAST SELF-CLOSED '+str(s.last),7)
            pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6)
            pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10)
        if u.dialog:
            pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8)
            pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10)
            pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7)
            pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6)
        if u.notice:
            msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved'
            pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10)
        if self.args.headless_smoke and self.frame==self.capture_frame:
            if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
            if self.args.state_json:
                data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'}
                with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2)
if __name__=='__main__':
    args=parse_args()
    import pyxel
    App(args)
AFTER STEP 05

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

ルールのまとめ:閉店 → 道 → 前の位置から同時に移動 → 買い物・帰り始め → 開いているお店の売り上げ → 刻。6刻のあとは片づけと帰り道だけ。自分で3つのお店を閉め、全員が帰れば達成。

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

比べる点5ステップの教材元の完成版
段階01 1人での道、02 全部のお店が開いたままの3人と6刻の練習、03 閉店・売り上げ・片づけ・帰り道、04 元のゲームのルールとお別れ・比較・Undo・自動、05 保存。最初から、すべての機能があります。
表示600×420の英数字(ASCII)。日本語のお別れの文は State/JSON に残し、Z の画面は見えている事実を表示。F12 での保存、元のゲームの出来事の記録、飾りは省略。1024×720の日本語の全文と文字。
構成それぞれの Python ファイルに、ルールと必要な入力の処理・MIT ライセンスの全文を入れています。外部に必要なのは Pyxel だけ。いくつものモジュールと glyphs.json。

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

  1. 元のゲームの model.py:edges/route
  2. 元のゲームの app.py:POINTS と PERMANENT の描画
  3. 元のゲームの model.py:goal/move_guests
  4. 元のゲームの controller.py:selection
  5. 元のゲームの model.py:step/legal/complete
  6. 元のゲームの model.py:edges/move_guests
  7. 元のゲームの model.py:goodbye/forecast
  8. 元のゲームの controller.py:feed の detail/walkout の分かれ道
  9. 元のゲームの app.py:update の auto_clock
  10. 元のゲームの storage.py:save/load
  11. 元のゲームの model.py:from_dict
  12. 元のゲームの app.py:wrap/record/flush_events
自分のゲームに変えるなら

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

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