シミュレーションLESSON 44架空の展示配置・離散来客シミュレーション原作068

小さな博物館の夜の作り方

となりに置いた品が、展示の意味を変える。
並べ方から生まれる「意味のタグ」と、来館者がどの展示を選ぶかを観察します。予告と記録の機能がある、小さな展示の実験を作ります。

完成版の遊び方4つの品の並べ方で、となりの展示の意味を増やし、来館者の興味と、歩ける予算に合うように届けます。

小さな博物館の夜の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の5ステップは原作Cを題材にします。
PLAY / 試験公開

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

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

完成版の主な操作:矢印台、Space品候補、Z配置、Enter開館/編集、P停止、X戻す、R再開

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

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

うまく表示されないとき

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

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

となり合うことで増える意味と、来館者が知っていることを分ける。

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

1. tuple・1つしかない品・書きかえない State・置きかえと移動のちがい
2. 上下左右のとなり・集合・おたがいの関係・つながっていかない計算
3. 絞りこみ・まだ知らないタグ・順位を決めるキー・同点の決め方・予告と確定
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.10以上、.venv とターミナル / 変数・if・for・関数・tuple/list/dict の基本 / dataclass・集合・並べかえのキー・ハッシュは本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
['01〜05: 矢印=台、編集中Space=品、Z=配置/戻す、R=初期化、Esc=終了', '03〜05: 編集中Tab=予告する来客', '04〜05: Enter=開館/編集、開館中Space=一つの観覧/退館、P=停止', '05: X=Undo、B=左右規則へ切替/初期化、S=指定JSONへ保存', '全段階: 一度に1キー、全キーを離して次を受付。同時押しは拒否']
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_exhibits.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

六つの台に四品を配置する

tuple・1つしかない品・書きかえない State・置きかえと移動のちがい。

元のゲームの状況 C「同じ碗の二つの話」の最初の配置は、上の段が臼 M・碗 B・種 T、下の段が網 N・空き・空きです。プログラムの中の台の番号は0〜5、画面では1〜6です。cell%3 と cell//3 で、2行3列の位置に対応させます。6つの品は、それぞれ1個しかありません。place は tuple を作業用の list にコピーし、チェックしてから replace で新しい State に確定します。同じ品を同じ台に置くと、収蔵庫(しまう場所)に戻ります。すでに置いた品を別の台に置くと、元の台は空きます。置く先に別の品があれば、その品は収蔵庫に戻ります。2つの品を元の台へ押し出して入れかえるのではありません。収蔵庫の品を空いた台に足すときだけ、すでに4品あれば受け付けません。0〜3品の途中の配置も、正しい編集の状態です。この段階はもとのタグを表示するだけで、となり合い・来館者・開館はまだ作りません。

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

01_exhibits.py / 注目する部分
def place(state, cell, item):
    """置く・戻す・移動・置換。成功した時だけ新しいStateを返す。"""
    if type(cell) is not int or not 0 <= cell < 6 or item not in ITEMS:
        return state, 'invalid'
    layout = list(state.layout)
    if layout[cell] == item:
        layout[cell] = ''
    else:
        if item in layout:
            layout[layout.index(item)] = ''
        elif sum(bool(x) for x in layout) == 4 and not layout[cell]:
            return state, 'four_only'
        layout[cell] = item
    return replace(state, layout=tuple(layout), tick=state.tick + 1), 'placed'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_exhibits.py
macOS / Linux
.venv/bin/python 01_exhibits.py
画面で見ること起動すると4/4品で、黄色い枠は台1、置く品の候補は種 T です。右 → Space → Z で候補を臼 M にして台2に置くと、台1は空き、台2は臼になり、碗は収蔵庫に戻ります。3/4品になり、LOGICAL TICK(ゲームの時刻)は1です。カーソルや候補を変えるだけでは、tick は進みません。これは、デモの画像の状態です。
  • プログラムの0から始まる番号と、画面の1から始まる番号を区別する
  • 品はどれも1つずつしかない
  • 置く先にあった品は、元の台には移らない
  • 0〜3品の、編集の途中の状態も残す
  • 受け付けないときは、状態を変えない
つまずきやすいところ4品を保つために、置く先の品を元の台へ自動で移すと、元のゲームとちがう「入れかえ」になってしまいます。また、4品より少ない状態をまちがいとして扱うと、外して作り直すことができなくなります。tuple を直接書きかえたり、受け付けなかったのに tick を増やしたりしないようにします。
少し変えてみよう

右 → Space → Z で、臼を碗の台に移して3品になることを確かめましょう。R で戻し、右 → Space → Space → Z なら、同じ碗を選んで外せます。さらに候補を網 → 舟と進め、空いた台に舟を置けば4品です。最初の状態の空いた台に鈴を足す操作は、four_only(4品まで)で受け付けられません。test_move_into_occupied_is_not_swap、test_same_item_removes、test_four_only_empty_and_replace が、そのことを確かめています。

01_exhibits.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(227行)
01_exhibits.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""068 小さな博物館の夜・日本語教材 1/5。架空の離散来客モデル。"""
from dataclasses import dataclass, asdict, replace

STAGE = 1
WIDTH, HEIGHT = 800, 560
TITLE = 'PLACE FOUR EXHIBITS'
ITEMS = ('T', 'M', 'B', 'N', 'V', 'L')
NAMES = {'T': '種', 'M': '臼', 'B': '碗', 'N': '網', 'V': '舟', 'L': '鈴'}
BASE = {'T': '育つ', 'M': '道具', 'B': '食', 'N': '海', 'V': '移動', 'L': '音'}
PRESET = ('M', 'B', 'T', 'N', '', '')
EN_ITEMS = {'T':'SEED', 'M':'MILL', 'B':'BOWL', 'N':'NET', 'V':'BOAT', 'L':'BELL'}
EN_TAGS = {'育つ':'GROW', '道具':'TOOL', '食':'FOOD', '海':'SEA', '移動':'MOVE',
           '音':'SOUND', 'くらし':'LIFE', '協力':'COOP', '合図':'SIGNAL', '循環':'CYCLE'}

@dataclass(frozen=True)
class State:
    layout: tuple = PRESET
    tick: int = 0

def initial():
    return State()

def xy(cell):
    return (cell % 3, cell // 3)

def place(state, cell, item):
    """置く・戻す・移動・置換。成功した時だけ新しいStateを返す。"""
    if type(cell) is not int or not 0 <= cell < 6 or item not in ITEMS:
        return state, 'invalid'
    layout = list(state.layout)
    if layout[cell] == item:
        layout[cell] = ''
    else:
        if item in layout:
            layout[layout.index(item)] = ''
        elif sum(bool(x) for x in layout) == 4 and not layout[cell]:
            return state, 'four_only'
        layout[cell] = item
    return replace(state, layout=tuple(layout), tick=state.tick + 1), 'placed'

def words(tags):
    return ','.join(EN_TAGS[tag] for tag in tags) or '-'

class App:
    def __init__(self, save_path=None):
        self.save_path = save_path
        self.horizontal = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cell = 0
        self.item = 0
        self.preview = 0
        self.paused = False
        self.gated = False
        self.actions = []
        self.reason = 'ready'
        self.last_save = None

    def act(self, action):
        _, values = action.split(':')
        cell, item = values.split(',')
        self.state, self.reason = place(self.state, int(cell), item)

    def control(self, pressed, held):
        # 教材の入力: 1キーずつ全解放。原作の同時押し優先順位は再現しない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.reason = 'release_all'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in CONTROL_KEYS:
            return
        self.gated = True
        if key == 'R':
            self.reset()
            self.gated = True
            return
        if key == 'LEFT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 - 1) % 3
        elif key == 'RIGHT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 + 1) % 3
        elif key in ('UP', 'DOWN'):
            self.cell = (self.cell + 3) % 6
        elif key == 'SPACE':
            self.item = (self.item + 1) % 6
        elif key == 'Z':
            self.act('place:' + str(self.cell) + ',' + ITEMS[self.item])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.rect(0, 0, WIDTH, 43, 2)
        p.text(12, 9, 'NIGHT MUSEUM / STAGE ' + str(STAGE), 10)
        p.text(12, 26, TITLE, 7)
        p.text(490, 9, 'FICTIONAL DISCRETE MODEL / NOT REAL AUDIENCES', 6)
        p.text(490, 26, '4 ITEMS / 6 STANDS / 3 INDEPENDENT VISITORS', 6)
        p.text(12, 53, 'ENTRY (-1,0) -> STAND 1    ARROWS: SELECT STAND', 7)
        p.text(12, 66, 'SELECTED ITEM: ' + ITEMS[self.item] + ' / ' + EN_ITEMS[ITEMS[self.item]], 10)
        tags = tuple((BASE[it],) if it else () for it in s.layout)
        links = ()
        for cell, it in enumerate(s.layout):
            x, y = 12 + (cell % 3) * 140, 86 + (cell // 3) * 96
            p.rect(x, y, 132, 86, 2)
            p.rectb(x, y, 132, 86, 10 if cell == self.cell else 5)
            p.text(x+7, y+7, 'STAND ' + str(cell+1), 6)
            p.text(x+7, y+26, (it + ' ' + EN_ITEMS[it]) if it else 'EMPTY', 7)
            for row, tag in enumerate(tags[cell]):
                p.text(x+7, y+43+row*10, EN_TAGS[tag], 11)
        count = sum(bool(it) for it in s.layout)
        p.text(12, 280, 'EXHIBITS: ' + str(count) + '/4    LOGICAL TICK: ' + str(s.tick), 10)
        p.text(12, 301, 'T=SEED/GROW  M=MILL/TOOL  B=BOWL/FOOD', 6)
        p.text(12, 314, 'N=NET/SEA    V=BOAT/MOVE  L=BELL/SOUND', 6)
        p.text(454, 63, 'PLACEMENT RULES', 10)
        lines = ('Z on same item: remove to store',
                 'Already placed item: move it',
                 'Occupied target: displace its old item',
                 'At 4 items: cannot add a fifth to EMPTY',
                 'Fewer than 4: valid unfinished design',
                 'No opening / visitors in this stage')
        for i, line in enumerate(lines):
            p.text(454, 86+i*22, line, 7)
        p.rect(10, 421, 780, 69, 0)
        p.text(18, 431, 'EDITING DOES NOT OPEN THE MUSEUM. NO AUTOMATIC VISITOR MOVEMENT.', 6)
        p.text(18, 449, 'Read LESSON.md for Japanese explanations, exact predictions and exercises.', 7)
        p.text(12, 500, 'STATUS: '+self.reason, 11)
        p.text(12, 516, 'ARROWS STAND | SPACE ITEM | Z PLACE / REMOVE | R RESET', 7)
        p.text(12, 534, 'ONE KEY AT A TIME. RELEASE ALL KEYS BEFORE NEXT INPUT. ESC QUIT.', 6)

CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R')
DEMO_KEYS = ('RIGHT', 'SPACE', 'Z')

def main(argv=None):
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args(argv)
    headless = args.headless_smoke or args.demo_checkpoint
    if (args.screenshot or args.report) and not headless:
        parser.error('--screenshot/--report require a headless mode')
    if args.screenshot and Path(args.screenshot).suffix.lower() != '.png':
        parser.error('--screenshot must end in .png')
    if args.report and Path(args.report).suffix.lower() != '.json':
        parser.error('--report must end in .json')
    outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v]
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    # 検査が済むまでPyxelを読み込まない。importとモデル試験は標準ライブラリだけ。
    import pyxel as p
    app = App()
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keymap = {k: getattr(p, 'KEY_'+k) for k in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='068 Night Museum - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            pressed = {sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keymap.items() if p.btnp(v)},
                        {k for k,v in keymap.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage': STAGE, 'kind': 'actual Pyxel headless automated render',
                        'keys': sequence, 'state': asdict(app.state), 'reason': app.reason,
                        'horizontal': app.horizontal, 'os_input_tested': False,
                        'human_play_tested': False, 'audio_tested': False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

if __name__ == '__main__':
    main()
STEP 02 / 05

隣接から両方の品へ文脈を加える

上下左右のとなり・集合・おたがいの関係・つながっていかない計算。

contexts は、並べ方だけから、それぞれの台のタグと、成り立つ組み合わせを計算する関数です(状態は書きかえません)。種=育つ、臼=道具、碗=食、網=海、舟=移動、鈴=音、というもとのタグを最初に入れます。上下左右に1つとなりの台の組だけを調べ、「種+臼=食」「臼+碗=くらし」「網+舟=協力」「舟+鈴=合図」「種+碗=循環」に当てはまったら、両方の台の集合に追加します。判定するのは品の組だけで、増えたタグから、また別の組み合わせが生まれることはありません。ななめや、上の段の右はしと下の段の左はしも、となりではありません。最初の配置の碗には、食・くらし・循環の3つのタグが付きます。となりの品をまだ見ていない来館者にも、意味として見える、というのがこの空想の作品の約束です。本物の博物館での解釈や、来館者の行動を証明する模型ではありません。

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

02_context_tags.py / 注目する部分
def contexts(layout, horizontal=False):
    tags = [set((BASE[it],)) if it else set() for it in layout]
    links = []
    for a in range(6):
        for b in range(a + 1, 6):
            if not adjacent(a, b, horizontal):
                continue
            for x, y, tag, story in PAIRS:
                if set((layout[a], layout[b])) == {x, y}:
                    tags[a].add(tag)
                    tags[b].add(tag)
                    links.append((a, b, tag, story))
    return (tuple((tuple(sorted(t)) for t in tags)), tuple(links))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_context_tags.py
macOS / Linux
.venv/bin/python 02_context_tags.py
画面で見ること最初の碗は LIFE、CYCLE、FOOD の3つの言葉を持ち、種には CYCLE と GROW、臼には LIFE と TOOL が表示されます。表示の順番は、日本語のタグを sorted で並べた順です。右 → 右 → Z で種を外すと、碗の CYCLE だけが消え、FOOD と LIFE は残ります。デモの画像は別の手順で、右 → Z で種を碗の場所に移し、種と臼に FOOD が付いた3品の配置です。
  • もとのタグは残す
  • 決まった品の組だけ、両方の台にタグを足す
  • 上下左右だけ。ななめと、行の折り返しはなし
  • 同じタグが重なっても1つと数える
  • 足したタグから、さらにつながっていかない
つまずきやすいところa+1 だけでとなりを調べると、台3と台4をまちがってつないでしまいます。足すタグを片方だけに付ける、もとのタグを置きかえてしまう、タグどうしを次々につなげる、というのもまちがいです。集合に入れるので、もとの「食」と足した「食」が重なっても、1つの言葉になります。
少し変えてみよう

上下に並んだ種と碗、ななめの種と碗、行をまたいだ台3と台4の種と碗を、紙に描いてみましょう。contexts では、上下に並んだときだけ「循環」ができ、ななめや行の折り返しではできません。種・臼・碗を上の段に並べても、種に「くらし」は伝わりません。test_orthogonal_not_diagonal_or_rowwrap、test_symmetric_nonchain が、これを確かめています。

02_context_tags.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(249行)
02_context_tags.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""068 小さな博物館の夜・日本語教材 2/5。架空の離散来客モデル。"""
from dataclasses import dataclass, asdict, replace

STAGE = 2
WIDTH, HEIGHT = 800, 560
TITLE = 'ADJACENCY ADDS CONTEXT'
ITEMS = ('T', 'M', 'B', 'N', 'V', 'L')
NAMES = {'T': '種', 'M': '臼', 'B': '碗', 'N': '網', 'V': '舟', 'L': '鈴'}
BASE = {'T': '育つ', 'M': '道具', 'B': '食', 'N': '海', 'V': '移動', 'L': '音'}
PRESET = ('M', 'B', 'T', 'N', '', '')
EN_ITEMS = {'T':'SEED', 'M':'MILL', 'B':'BOWL', 'N':'NET', 'V':'BOAT', 'L':'BELL'}
EN_TAGS = {'育つ':'GROW', '道具':'TOOL', '食':'FOOD', '海':'SEA', '移動':'MOVE',
           '音':'SOUND', 'くらし':'LIFE', '協力':'COOP', '合図':'SIGNAL', '循環':'CYCLE'}

PAIRS = (('T', 'M', '食', '種を臼でひいて食べ物へ'), ('M', 'B', 'くらし', '臼でひいた粉を碗によそう'), ('N', 'V', '協力', '舟と網で協力した漁'), ('V', 'L', '合図', '舟から鈴で知らせた合図'), ('T', 'B', '循環', '使い終わった碗で種を育てた工夫'))

@dataclass(frozen=True)
class State:
    layout: tuple = PRESET
    tick: int = 0

def initial():
    return State()

def xy(cell):
    return (cell % 3, cell // 3)

def adjacent(a, b, horizontal=False):
    x, y = xy(a)
    u, v = xy(b)
    return y == v and abs(x - u) == 1 if horizontal else abs(x - u) + abs(y - v) == 1

def contexts(layout, horizontal=False):
    tags = [set((BASE[it],)) if it else set() for it in layout]
    links = []
    for a in range(6):
        for b in range(a + 1, 6):
            if not adjacent(a, b, horizontal):
                continue
            for x, y, tag, story in PAIRS:
                if set((layout[a], layout[b])) == {x, y}:
                    tags[a].add(tag)
                    tags[b].add(tag)
                    links.append((a, b, tag, story))
    return (tuple((tuple(sorted(t)) for t in tags)), tuple(links))

def place(state, cell, item):
    """置く・戻す・移動・置換。成功した時だけ新しいStateを返す。"""
    if type(cell) is not int or not 0 <= cell < 6 or item not in ITEMS:
        return state, 'invalid'
    layout = list(state.layout)
    if layout[cell] == item:
        layout[cell] = ''
    else:
        if item in layout:
            layout[layout.index(item)] = ''
        elif sum(bool(x) for x in layout) == 4 and not layout[cell]:
            return state, 'four_only'
        layout[cell] = item
    return replace(state, layout=tuple(layout), tick=state.tick + 1), 'placed'

def words(tags):
    return ','.join(EN_TAGS[tag] for tag in tags) or '-'

class App:
    def __init__(self, save_path=None):
        self.save_path = save_path
        self.horizontal = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cell = 0
        self.item = 0
        self.preview = 0
        self.paused = False
        self.gated = False
        self.actions = []
        self.reason = 'ready'
        self.last_save = None

    def act(self, action):
        _, values = action.split(':')
        cell, item = values.split(',')
        self.state, self.reason = place(self.state, int(cell), item)

    def control(self, pressed, held):
        # 教材の入力: 1キーずつ全解放。原作の同時押し優先順位は再現しない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.reason = 'release_all'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in CONTROL_KEYS:
            return
        self.gated = True
        if key == 'R':
            self.reset()
            self.gated = True
            return
        if key == 'LEFT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 - 1) % 3
        elif key == 'RIGHT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 + 1) % 3
        elif key in ('UP', 'DOWN'):
            self.cell = (self.cell + 3) % 6
        elif key == 'SPACE':
            self.item = (self.item + 1) % 6
        elif key == 'Z':
            self.act('place:' + str(self.cell) + ',' + ITEMS[self.item])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.rect(0, 0, WIDTH, 43, 2)
        p.text(12, 9, 'NIGHT MUSEUM / STAGE ' + str(STAGE), 10)
        p.text(12, 26, TITLE, 7)
        p.text(490, 9, 'FICTIONAL DISCRETE MODEL / NOT REAL AUDIENCES', 6)
        p.text(490, 26, '4 ITEMS / 6 STANDS / 3 INDEPENDENT VISITORS', 6)
        p.text(12, 53, 'ENTRY (-1,0) -> STAND 1    ARROWS: SELECT STAND', 7)
        p.text(12, 66, 'SELECTED ITEM: ' + ITEMS[self.item] + ' / ' + EN_ITEMS[ITEMS[self.item]], 10)
        tags, links = contexts(s.layout, self.horizontal)
        for cell, it in enumerate(s.layout):
            x, y = 12 + (cell % 3) * 140, 86 + (cell // 3) * 96
            p.rect(x, y, 132, 86, 2)
            p.rectb(x, y, 132, 86, 10 if cell == self.cell else 5)
            p.text(x+7, y+7, 'STAND ' + str(cell+1), 6)
            p.text(x+7, y+26, (it + ' ' + EN_ITEMS[it]) if it else 'EMPTY', 7)
            for row, tag in enumerate(tags[cell]):
                p.text(x+7, y+43+row*10, EN_TAGS[tag], 11)
        count = sum(bool(it) for it in s.layout)
        p.text(12, 280, 'EXHIBITS: ' + str(count) + '/4    LOGICAL TICK: ' + str(s.tick), 10)
        p.text(12, 302, 'CONTEXT: ' + ('HORIZONTAL ONLY' if self.horizontal else 'ORTHOGONAL / NO DIAGONALS'), 6)
        for i, (a, b, tag, story) in enumerate(PAIRS):
            active = any(link[2] == tag for link in links)
            p.text(12, 320+i*15, ('+ ' if active else '. ') + a + '+' + b + ' -> ' + EN_TAGS[tag], 11 if active else 5)
        p.text(454, 63, 'CONTEXT IS AVAILABLE BEFORE VISITING', 10)
        lines = ('A matching pair adds a tag to BOTH items.',
                 'Base tags remain. Tags are a set.',
                 'Only item pairs match; tags never chain.',
                 'An unseen neighbor can supply context.',
                 'Initial bowl: FOOD + LIFE + CYCLE',
                 'Visitors and budgets arrive in stage 03.')
        for i, line in enumerate(lines):
            p.text(454, 86+i*22, line, 7)
        p.rect(10, 421, 780, 69, 0)
        p.text(18, 431, 'EDITING DOES NOT OPEN THE MUSEUM. NO AUTOMATIC VISITOR MOVEMENT.', 6)
        p.text(18, 449, 'Read LESSON.md for Japanese explanations, exact predictions and exercises.', 7)
        p.text(12, 500, 'STATUS: '+self.reason, 11)
        p.text(12, 516, 'ARROWS STAND | SPACE ITEM | Z PLACE / REMOVE | R RESET', 7)
        p.text(12, 534, 'ONE KEY AT A TIME. RELEASE ALL KEYS BEFORE NEXT INPUT. ESC QUIT.', 6)

CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R')
DEMO_KEYS = ('RIGHT', 'Z')

def main(argv=None):
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args(argv)
    headless = args.headless_smoke or args.demo_checkpoint
    if (args.screenshot or args.report) and not headless:
        parser.error('--screenshot/--report require a headless mode')
    if args.screenshot and Path(args.screenshot).suffix.lower() != '.png':
        parser.error('--screenshot must end in .png')
    if args.report and Path(args.report).suffix.lower() != '.json':
        parser.error('--report must end in .json')
    outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v]
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    # 検査が済むまでPyxelを読み込まない。importとモデル試験は標準ライブラリだけ。
    import pyxel as p
    app = App()
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keymap = {k: getattr(p, 'KEY_'+k) for k in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='068 Night Museum - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            pressed = {sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keymap.items() if p.btnp(v)},
                        {k for k,v in keymap.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage': STAGE, 'kind': 'actual Pyxel headless automated render',
                        'keys': sequence, 'state': asdict(app.state), 'reason': app.reason,
                        'horizontal': app.horizontal, 'os_input_tested': False,
                        'human_play_tested': False, 'audio_tested': False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

if __name__ == '__main__':
    main()
STEP 03 / 05

興味・未取得タグ・費用で次の一手を予告する

絞りこみ・まだ知らないタグ・順位を決めるキー・同点の決め方・予告と確定。

Visitor(来館者)は、名前・興味・知りたい目標のタグ・予算を分けて持ちます。状況 C のアサは、道具とくらしに興味があり、目標は「くらし」、予算5です。カナは育つと循環に興味、目標は「循環」、予算5。ミオは食と海に興味、目標は「食」、予算7です。入口は (-1,0) で、費用は「移動の上下左右の距離+見るのに1」です。展示は、歩くときのじゃまにはなりません。choices は、まだ見ていなくて予算の中で行ける展示だけを残し、その台のタグから learned(もう知っているタグ)を引きます。新しく知る興味のタグは2点、それ以外の新しいタグは1点なので、len(new)+len(interest) が「見たい度合い」になります。もう知っているタグは0点で、見たい度合いが0の候補は外します。順位は「見たい度合いが大きい順 → 費用が小さい順 → 台の番号が小さい順」です。見たい度合いを費用で割る、効率の計算ではありません。目標のタグは、帰るときの判定に使い、直接の加点や、そこで帰らせる条件にはしません。

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

03_choice_trace.py / 注目する部分
def choices(layout, p, position, budget, seen, learned, horizontal=False):
    tags, _ = contexts(layout, horizontal)
    options = []
    for cell, it in enumerate(layout):
        if not it or it in seen:
            continue
        x, y = xy(cell)
        cost = abs(x - position[0]) + abs(y - position[1]) + 1
        if cost > budget:
            continue
        new = tuple((t for t in tags[cell] if t not in learned))
        interest = tuple((t for t in new if t in p.interests))
        attraction = len(new) + len(interest)
        if attraction:
            options.append(Choice(cell, it, cost, attraction, new, interest))
    return tuple(sorted(options, key=lambda c: (-c.attraction, c.cost, c.cell)))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_choice_trace.py
macOS / Linux
.venv/bin/python 03_choice_trace.py
画面で見ることアサの最初の候補は臼 M、見たい度合い4、費用2です。碗も見たい度合い4ですが、費用3なので2番目です。Tab でカナに切りかえると、碗 B(見たい度合い4、費用3)が先頭です。種も見たい度合い4ですが、費用4です。さらに Tab でミオにしても、碗 B(見たい度合い4、費用3)です。予告では、知ることもお金を使うことも起きず、State の tick は0のままです。3品以下でも予告はできますが、まだ開館の操作はありません。
  • 見た品と、知ったタグは別々に持つ
  • 予算ちょうどの展示も候補に残す
  • 見たい度合いがいちばん大きいもの。同点なら費用、次に台の番号
  • 予告では状態を変えない
  • 興味と目標の役割を分ける
つまずきやすいところ興味のタグだけを数えると、興味以外の新しい言葉の1点が消えてしまいます。もう知っているタグを毎回数えると、同じことを何度も知って点が増えてしまいます。目標のタグを知った時点で帰らせるのも、元のゲームとちがいます。同点のときに、set を読む順番まかせにせず、はっきり決めた tuple のキーを使います。
少し変えてみよう

新しいアサにとって、臼は「くらし2+道具2=4」、碗は「くらし2+循環1+食1=4」と手で計算し、費用2と3を比べて臼を選びます。カナでは、碗の「循環2+くらし1+食1=4」と、種の「循環2+育つ2=4」を、費用3と4で比べます。碗と網だけを台1と台3に置き、位置 (1,0)・興味なし・予算9にすると、どちらも見たい度合い1・費用2なので台1が先です。入口から台1を見る費用は2なので、予算2なら行けますが、1なら行けません。

03_choice_trace.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(307行)
03_choice_trace.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""068 小さな博物館の夜・日本語教材 3/5。架空の離散来客モデル。"""
from dataclasses import dataclass, asdict, replace

STAGE = 3
WIDTH, HEIGHT = 800, 560
TITLE = 'PREVIEW A DETERMINISTIC CHOICE'
ITEMS = ('T', 'M', 'B', 'N', 'V', 'L')
NAMES = {'T': '種', 'M': '臼', 'B': '碗', 'N': '網', 'V': '舟', 'L': '鈴'}
BASE = {'T': '育つ', 'M': '道具', 'B': '食', 'N': '海', 'V': '移動', 'L': '音'}
PRESET = ('M', 'B', 'T', 'N', '', '')
EN_ITEMS = {'T':'SEED', 'M':'MILL', 'B':'BOWL', 'N':'NET', 'V':'BOAT', 'L':'BELL'}
EN_TAGS = {'育つ':'GROW', '道具':'TOOL', '食':'FOOD', '海':'SEA', '移動':'MOVE',
           '音':'SOUND', 'くらし':'LIFE', '協力':'COOP', '合図':'SIGNAL', '循環':'CYCLE'}

PAIRS = (('T', 'M', '食', '種を臼でひいて食べ物へ'), ('M', 'B', 'くらし', '臼でひいた粉を碗によそう'), ('N', 'V', '協力', '舟と網で協力した漁'), ('V', 'L', '合図', '舟から鈴で知らせた合図'), ('T', 'B', '循環', '使い終わった碗で種を育てた工夫'))

@dataclass(frozen=True)
class Visitor:
    name: str
    interests: tuple
    goal: str
    budget: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    visitors: tuple
    layout: tuple

LEVEL = Level('068-C-g1', '同じ碗の二つの話', (Visitor('アサ', ('道具', 'くらし'), 'くらし', 5), Visitor('カナ', ('育つ', '循環'), '循環', 5), Visitor('ミオ', ('食', '海'), '食', 7)), PRESET)
LEVELS = (LEVEL,)

@dataclass(frozen=True)
class Choice:
    cell: int
    item: str
    cost: int
    attraction: int
    new: tuple
    interest_new: tuple

@dataclass(frozen=True)
class State:
    layout: tuple = PRESET
    tick: int = 0

def initial():
    return State()

def xy(cell):
    return (cell % 3, cell // 3)

def adjacent(a, b, horizontal=False):
    x, y = xy(a)
    u, v = xy(b)
    return y == v and abs(x - u) == 1 if horizontal else abs(x - u) + abs(y - v) == 1

def contexts(layout, horizontal=False):
    tags = [set((BASE[it],)) if it else set() for it in layout]
    links = []
    for a in range(6):
        for b in range(a + 1, 6):
            if not adjacent(a, b, horizontal):
                continue
            for x, y, tag, story in PAIRS:
                if set((layout[a], layout[b])) == {x, y}:
                    tags[a].add(tag)
                    tags[b].add(tag)
                    links.append((a, b, tag, story))
    return (tuple((tuple(sorted(t)) for t in tags)), tuple(links))

def choices(layout, p, position, budget, seen, learned, horizontal=False):
    tags, _ = contexts(layout, horizontal)
    options = []
    for cell, it in enumerate(layout):
        if not it or it in seen:
            continue
        x, y = xy(cell)
        cost = abs(x - position[0]) + abs(y - position[1]) + 1
        if cost > budget:
            continue
        new = tuple((t for t in tags[cell] if t not in learned))
        interest = tuple((t for t in new if t in p.interests))
        attraction = len(new) + len(interest)
        if attraction:
            options.append(Choice(cell, it, cost, attraction, new, interest))
    return tuple(sorted(options, key=lambda c: (-c.attraction, c.cost, c.cell)))

def place(state, cell, item):
    """置く・戻す・移動・置換。成功した時だけ新しいStateを返す。"""
    if type(cell) is not int or not 0 <= cell < 6 or item not in ITEMS:
        return state, 'invalid'
    layout = list(state.layout)
    if layout[cell] == item:
        layout[cell] = ''
    else:
        if item in layout:
            layout[layout.index(item)] = ''
        elif sum(bool(x) for x in layout) == 4 and not layout[cell]:
            return state, 'four_only'
        layout[cell] = item
    return replace(state, layout=tuple(layout), tick=state.tick + 1), 'placed'

def words(tags):
    return ','.join(EN_TAGS[tag] for tag in tags) or '-'

class App:
    def __init__(self, save_path=None):
        self.save_path = save_path
        self.horizontal = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cell = 0
        self.item = 0
        self.preview = 0
        self.paused = False
        self.gated = False
        self.actions = []
        self.reason = 'ready'
        self.last_save = None

    def act(self, action):
        _, values = action.split(':')
        cell, item = values.split(',')
        self.state, self.reason = place(self.state, int(cell), item)

    def control(self, pressed, held):
        # 教材の入力: 1キーずつ全解放。原作の同時押し優先順位は再現しない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.reason = 'release_all'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in CONTROL_KEYS:
            return
        self.gated = True
        if key == 'R':
            self.reset()
            self.gated = True
            return
        if key == 'TAB':
            self.preview = (self.preview + 1) % 3
            return
        if key == 'LEFT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 - 1) % 3
        elif key == 'RIGHT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 + 1) % 3
        elif key in ('UP', 'DOWN'):
            self.cell = (self.cell + 3) % 6
        elif key == 'SPACE':
            self.item = (self.item + 1) % 6
        elif key == 'Z':
            self.act('place:' + str(self.cell) + ',' + ITEMS[self.item])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.rect(0, 0, WIDTH, 43, 2)
        p.text(12, 9, 'NIGHT MUSEUM / STAGE ' + str(STAGE), 10)
        p.text(12, 26, TITLE, 7)
        p.text(490, 9, 'FICTIONAL DISCRETE MODEL / NOT REAL AUDIENCES', 6)
        p.text(490, 26, '4 ITEMS / 6 STANDS / 3 INDEPENDENT VISITORS', 6)
        p.text(12, 53, 'ENTRY (-1,0) -> STAND 1    ARROWS: SELECT STAND', 7)
        p.text(12, 66, 'SELECTED ITEM: ' + ITEMS[self.item] + ' / ' + EN_ITEMS[ITEMS[self.item]], 10)
        tags, links = contexts(s.layout, self.horizontal)
        for cell, it in enumerate(s.layout):
            x, y = 12 + (cell % 3) * 140, 86 + (cell // 3) * 96
            p.rect(x, y, 132, 86, 2)
            p.rectb(x, y, 132, 86, 10 if cell == self.cell else 5)
            p.text(x+7, y+7, 'STAND ' + str(cell+1), 6)
            p.text(x+7, y+26, (it + ' ' + EN_ITEMS[it]) if it else 'EMPTY', 7)
            for row, tag in enumerate(tags[cell]):
                p.text(x+7, y+43+row*10, EN_TAGS[tag], 11)
        count = sum(bool(it) for it in s.layout)
        p.text(12, 280, 'EXHIBITS: ' + str(count) + '/4    LOGICAL TICK: ' + str(s.tick), 10)
        p.text(12, 302, 'CONTEXT: ' + ('HORIZONTAL ONLY' if self.horizontal else 'ORTHOGONAL / NO DIAGONALS'), 6)
        for i, (a, b, tag, story) in enumerate(PAIRS):
            active = any(link[2] == tag for link in links)
            p.text(12, 320+i*15, ('+ ' if active else '. ') + a + '+' + b + ' -> ' + EN_TAGS[tag], 11 if active else 5)
        who = self.preview
        position, budget, seen, learned = (-1, 0), LEVEL.visitors[who].budget, (), ()
        visitor = LEVEL.visitors[who]
        options = choices(s.layout, visitor, position, budget, seen, learned, self.horizontal)
        p.text(454, 57, ('CURRENT' if hasattr(s, 'mode') and s.mode == 'run' else 'PREVIEW') + ' VISITOR: ' + ('ASA','KANA','MIO')[who], 10)
        p.text(454, 71, 'INTEREST: ' + words(visitor.interests) + '  GOAL: ' + EN_TAGS[visitor.goal], 7)
        p.text(454, 85, 'POSITION ' + str(position) + '  BUDGET ' + str(budget), 6)
        p.text(454, 99, 'KNOWN: ' + words(learned), 6)
        p.text(454, 120, 'ORDER: ATTRACTION DESC, COST ASC, STAND ASC', 10)
        p.text(454, 133, 'NEW INTEREST x2 + OTHER NEW x1; KNOWN = 0', 6)
        for i, choice in enumerate(options):
            y = 153+i*38
            p.text(454, y, str(i+1)+'. STAND '+str(choice.cell+1)+' '+choice.item+'  SCORE '+str(choice.attraction)+'  COST '+str(choice.cost), 11 if i == 0 else 7)
            p.text(466, y+11, '2x: '+words(choice.interest_new), 11)
            p.text(466, y+22, '1x: '+words(tuple(t for t in choice.new if t not in choice.interest_new)), 6)
        if not options:
            p.text(454, 157, 'NO POSITIVE AFFORDABLE UNSEEN CHOICE', 9)
        p.text(454, 326, 'TAB: PREVIEW NEXT VISITOR', 10)
        p.text(454, 343, 'Preview does not spend budget or learn tags.', 7)
        p.text(454, 360, 'Partial designs can be previewed.', 6)
        p.text(454, 377, 'No opening / commit in this stage.', 6)
        p.rect(10, 421, 780, 69, 0)
        p.text(18, 431, 'EDITING DOES NOT OPEN THE MUSEUM. NO AUTOMATIC VISITOR MOVEMENT.', 6)
        p.text(18, 449, 'Read LESSON.md for Japanese explanations, exact predictions and exercises.', 7)
        p.text(12, 500, 'STATUS: '+self.reason, 11)
        p.text(12, 516, 'ARROWS STAND | SPACE ITEM | Z PLACE / REMOVE | R RESET', 7)
        p.text(12, 534, 'ONE KEY AT A TIME. RELEASE ALL KEYS BEFORE NEXT INPUT. ESC QUIT.', 6)

CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R', 'TAB')
DEMO_KEYS = ('TAB',)

def main(argv=None):
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args(argv)
    headless = args.headless_smoke or args.demo_checkpoint
    if (args.screenshot or args.report) and not headless:
        parser.error('--screenshot/--report require a headless mode')
    if args.screenshot and Path(args.screenshot).suffix.lower() != '.png':
        parser.error('--screenshot must end in .png')
    if args.report and Path(args.report).suffix.lower() != '.json':
        parser.error('--report must end in .json')
    outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v]
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    # 検査が済むまでPyxelを読み込まない。importとモデル試験は標準ライブラリだけ。
    import pyxel as p
    app = App()
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keymap = {k: getattr(p, 'KEY_'+k) for k in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='068 Night Museum - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            pressed = {sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keymap.items() if p.btnp(v)},
                        {k for k,v in keymap.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage': STAGE, 'kind': 'actual Pyxel headless automated render',
                        'keys': sequence, 'state': asdict(app.state), 'reason': app.reason,
                        'horizontal': app.horizontal, 'os_input_tested': False,
                        'human_play_tested': False, 'audio_tested': False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

if __name__ == '__main__':
    main()
STEP 04 / 05

一つの選択を確定し、来客ごとの旧状態を残す

frozen な dataclass・replace・選ぶことと確定すること・3人別々の状態・帰る理由。

4品がそろった編集の状態で Enter を押すと、入口・予算5・まだ何も見ていない・何も知らないアサで開館します。Space 1回が advance 1回です。next_choice で同じルールを計算し、候補があれば費用を引き、位置・見た品・知ったタグを更新して、Visit(見学の記録)を追加します。候補がなければ、その人の Result(結果)を確定し、次の人を入口・新しい予算・空の seen/learned で始めます。前の人の記録は、visits/results に残します。3人目が帰ると done(終了)で、3人のうち2人以上がそれぞれの目標を知れば、基本の達成です。目標を知った直後でも、ほかに行ける候補があれば、見学を続けます。帰る理由は「新しく知ることがない」「残りの予算では届かない」「すべての展示を見た」を区別します。帰り道の費用は引きません。教材では判断を読めるように、手で1歩ずつ進める作りに変え、元のゲームの「42フレームごとに自動で進む」は省いています。

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

04_visitors.py / 注目する部分
    if action == 'advance':
        if s.mode != 'run':
            return (s, 'not_open')
        choice = next_choice(s, l, horizontal)
        if choice:
            learned = tuple(sorted(set(s.learned) | set(choice.new)))
            budget = s.budget - choice.cost
            visit = Visit(s.visitor, choice.cell, choice.item, choice.cost, choice.attraction, choice.new, budget, learned)
            return (replace(s, position=xy(choice.cell), budget=budget, seen=s.seen + (choice.item,), learned=learned, visits=s.visits + (visit,), tick=s.tick + 1), 'viewed')
        p = l.visitors[s.visitor]
        possible = [i for i, it in enumerate(s.layout) if it and it not in s.seen]
        affordable = any((abs(xy(i)[0] - s.position[0]) + abs(xy(i)[1] - s.position[1]) + 1 <= s.budget for i in possible))
        reason = '新しい理解が増えない' if affordable else '残り予算では届かない' if possible else '全ての展示を見た'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_visitors.py
macOS / Linux
.venv/bin/python 04_visitors.py
画面で見ることEnter の直後は、まだだれも展示を見ていません。Space 1回で、アサが臼へ進み、予算5→3になり、くらしと道具を知ります。もう1回で碗へ進み、予算3→1、新しく循環と食を知ります。3回目でアサが帰り、カナが入口・予算5・知っていることなしで始まります。前の人が知ったことは消さずに、結果の欄と履歴に残っています。最初の配置で合計9回 Space を押すと終わり、3/3達成、tick=10 です。これはデモの手順で、人が自分で遊んだ結果ではありません。
  • 選ぶことと、確定することを分ける
  • 知っているタグと見た品は、来館者ごとに最初から
  • 前の人の Visit/Result を残す
  • 1回の入力で、見るか帰るかのどちらか1つだけ
  • 基本の目標は2/3、全員が帰ったら終わり
つまずきやすいところdraw や予告で予算を使うと、画面を描く回数でルールが変わってしまいます。次の人に seen/learned を渡すと、3人が別々ではなくなります。また、候補がないフレームで「帰る」と「次の人の1歩」を同時に確定しないでください。1回の advance は、1回の見学か、1人が帰ることのどちらかです。
少し変えてみよう

Enter → Space を3回押して、カナが入口にいる状態と、アサの残りの予算1・道すじ M,B・成功を確かめましょう。P で一時停止して Space や Enter を押しても状態が変わらず、P で戻してから Space で進むことも確かめてください。最初の状況 C を最後まで進めると、道すじはアサ M,B、カナ B,T、ミオ B,N、残りの予算は 1,0,1 です。別の配置 (T,M,N,B,空,空) では2/3の達成で、基本の目標を満たします。3人全員を必須にしないでください。

04_visitors.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(422行)
04_visitors.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""068 小さな博物館の夜・日本語教材 4/5。架空の離散来客モデル。"""
from dataclasses import dataclass, asdict, replace

STAGE = 4
WIDTH, HEIGHT = 800, 560
TITLE = 'COMMIT ONE VISITOR STEP'
ITEMS = ('T', 'M', 'B', 'N', 'V', 'L')
NAMES = {'T': '種', 'M': '臼', 'B': '碗', 'N': '網', 'V': '舟', 'L': '鈴'}
BASE = {'T': '育つ', 'M': '道具', 'B': '食', 'N': '海', 'V': '移動', 'L': '音'}
PRESET = ('M', 'B', 'T', 'N', '', '')
EN_ITEMS = {'T':'SEED', 'M':'MILL', 'B':'BOWL', 'N':'NET', 'V':'BOAT', 'L':'BELL'}
EN_TAGS = {'育つ':'GROW', '道具':'TOOL', '食':'FOOD', '海':'SEA', '移動':'MOVE',
           '音':'SOUND', 'くらし':'LIFE', '協力':'COOP', '合図':'SIGNAL', '循環':'CYCLE'}

PAIRS = (('T', 'M', '食', '種を臼でひいて食べ物へ'), ('M', 'B', 'くらし', '臼でひいた粉を碗によそう'), ('N', 'V', '協力', '舟と網で協力した漁'), ('V', 'L', '合図', '舟から鈴で知らせた合図'), ('T', 'B', '循環', '使い終わった碗で種を育てた工夫'))

@dataclass(frozen=True)
class Visitor:
    name: str
    interests: tuple
    goal: str
    budget: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    visitors: tuple
    layout: tuple

LEVEL = Level('068-C-g1', '同じ碗の二つの話', (Visitor('アサ', ('道具', 'くらし'), 'くらし', 5), Visitor('カナ', ('育つ', '循環'), '循環', 5), Visitor('ミオ', ('食', '海'), '食', 7)), PRESET)
LEVELS = (LEVEL,)

@dataclass(frozen=True)
class Choice:
    cell: int
    item: str
    cost: int
    attraction: int
    new: tuple
    interest_new: tuple

@dataclass(frozen=True)
class Visit:
    visitor: int
    cell: int
    item: str
    cost: int
    attraction: int
    new: tuple
    budget_after: int
    learned: tuple

@dataclass(frozen=True)
class Result:
    visitor: int
    learned: tuple
    budget_left: int
    seen: tuple
    success: bool
    reason: str

@dataclass(frozen=True)
class State:
    layout: tuple
    mode: str = 'edit'
    visitor: int = 0
    position: tuple = (-1, 0)
    budget: int = 0
    seen: tuple = ()
    learned: tuple = ()
    visits: tuple = ()
    results: tuple = ()
    tick: int = 0

def initial():
    return State(PRESET)

def xy(cell):
    return (cell % 3, cell // 3)

def adjacent(a, b, horizontal=False):
    x, y = xy(a)
    u, v = xy(b)
    return y == v and abs(x - u) == 1 if horizontal else abs(x - u) + abs(y - v) == 1

def contexts(layout, horizontal=False):
    tags = [set((BASE[it],)) if it else set() for it in layout]
    links = []
    for a in range(6):
        for b in range(a + 1, 6):
            if not adjacent(a, b, horizontal):
                continue
            for x, y, tag, story in PAIRS:
                if set((layout[a], layout[b])) == {x, y}:
                    tags[a].add(tag)
                    tags[b].add(tag)
                    links.append((a, b, tag, story))
    return (tuple((tuple(sorted(t)) for t in tags)), tuple(links))

def choices(layout, p, position, budget, seen, learned, horizontal=False):
    tags, _ = contexts(layout, horizontal)
    options = []
    for cell, it in enumerate(layout):
        if not it or it in seen:
            continue
        x, y = xy(cell)
        cost = abs(x - position[0]) + abs(y - position[1]) + 1
        if cost > budget:
            continue
        new = tuple((t for t in tags[cell] if t not in learned))
        interest = tuple((t for t in new if t in p.interests))
        attraction = len(new) + len(interest)
        if attraction:
            options.append(Choice(cell, it, cost, attraction, new, interest))
    return tuple(sorted(options, key=lambda c: (-c.attraction, c.cost, c.cell)))

def next_choice(s, l, horizontal=False):
    if s.mode != 'run':
        return None
    options = choices(s.layout, l.visitors[s.visitor], s.position, s.budget, s.seen, s.learned, horizontal)
    return options[0] if options else None

def advance(s, l, action, horizontal=False):
    op, sep, arg = action.partition(':')
    if op == 'place' and sep:
        if s.mode != 'edit':
            return (s, 'locked')
        bits = arg.split(',')
        if len(bits) != 2 or not bits[0].isdigit():
            return (s, 'invalid')
        cell = int(bits[0])
        item = bits[1]
        if not 0 <= cell < 6 or item not in ITEMS:
            return (s, 'invalid')
        layout = list(s.layout)
        if layout[cell] == item:
            layout[cell] = ''
        else:
            if item in layout:
                layout[layout.index(item)] = ''
            elif sum((bool(x) for x in layout)) == 4 and (not layout[cell]):
                return (s, 'four_only')
            layout[cell] = item
        return (replace(s, layout=tuple(layout), tick=s.tick + 1), 'placed')
    if action == 'open':
        if s.mode != 'edit':
            return (s, 'locked')
        if sum((bool(x) for x in s.layout)) != 4:
            return (s, 'need_four')
        return (State(s.layout, 'run', 0, (-1, 0), l.visitors[0].budget, tick=s.tick + 1), 'opened')
    if action == 'advance':
        if s.mode != 'run':
            return (s, 'not_open')
        choice = next_choice(s, l, horizontal)
        if choice:
            learned = tuple(sorted(set(s.learned) | set(choice.new)))
            budget = s.budget - choice.cost
            visit = Visit(s.visitor, choice.cell, choice.item, choice.cost, choice.attraction, choice.new, budget, learned)
            return (replace(s, position=xy(choice.cell), budget=budget, seen=s.seen + (choice.item,), learned=learned, visits=s.visits + (visit,), tick=s.tick + 1), 'viewed')
        p = l.visitors[s.visitor]
        possible = [i for i, it in enumerate(s.layout) if it and it not in s.seen]
        affordable = any((abs(xy(i)[0] - s.position[0]) + abs(xy(i)[1] - s.position[1]) + 1 <= s.budget for i in possible))
        reason = '新しい理解が増えない' if affordable else '残り予算では届かない' if possible else '全ての展示を見た'
        result = Result(s.visitor, s.learned, s.budget, s.seen, p.goal in s.learned, reason)
        n = s.visitor + 1
        return (replace(s, mode='done' if n == 3 else 'run', visitor=n, position=(-1, 0), budget=l.visitors[n].budget if n < 3 else 0, seen=(), learned=(), results=s.results + (result,), tick=s.tick + 1), 'closed' if n == 3 else 'next_visitor')
    if action == 'edit':
        if s.mode not in ('run', 'done'):
            return (s, 'not_open')
        return (State(s.layout, tick=s.tick + 1), 'editing')
    return (s, 'unknown')

def forecast(s, l, horizontal=False):
    tags, links = contexts(s.layout, horizontal)
    first = tuple(((choices(s.layout, p, (-1, 0), p.budget, (), (), horizontal) or (None,))[0] for p in l.visitors))
    return {'tags': tags, 'links': links, 'first': first, 'next': next_choice(s, l, horizontal)}

def simulate(l, layout=None, horizontal=False):
    s = State(tuple(layout or l.layout))
    s, _ = advance(s, l, 'open', horizontal)
    if s.mode != 'run':
        raise ValueError('four unique items required')
    while s.mode == 'run':
        s, _ = advance(s, l, 'advance', horizontal)
    return s

def invariant(s, l, horizontal=False):
    its = tuple((x for x in s.layout if x))
    assert len(its) <= 4 and len(set(its)) == len(its) and (set(its) <= set(ITEMS))
    assert 0 <= s.visitor <= 3 and 0 <= s.budget and (s.visitor == len(s.results))
    if s.mode == 'run':
        assert len(its) == 4 and s.budget <= l.visitors[s.visitor].budget
    tags, _ = contexts(s.layout, horizontal)
    for v in s.visits:
        assert set(v.new) <= set(tags[v.cell]) and v.item == s.layout[v.cell]
    return True

def words(tags):
    return ','.join(EN_TAGS[tag] for tag in tags) or '-'

class App:
    def __init__(self, save_path=None):
        self.save_path = save_path
        self.horizontal = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cell = 0
        self.item = 0
        self.preview = 0
        self.paused = False
        self.gated = False
        self.actions = []
        self.reason = 'ready'
        self.last_save = None

    def act(self, action):
        self.state, self.reason = advance(self.state, LEVEL, action)
        self.actions.append(action)

    def control(self, pressed, held):
        # 教材の入力: 1キーずつ全解放。原作の同時押し優先順位は再現しない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.reason = 'release_all'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in CONTROL_KEYS:
            return
        self.gated = True
        if key == 'R':
            self.reset()
            self.gated = True
            return
        if key == 'P':
            self.paused = not self.paused
            return
        if self.paused:
            return
        if key == 'RETURN':
            self.act('open' if self.state.mode == 'edit' else 'edit')
            return
        if self.state.mode == 'run':
            if key == 'SPACE':
                self.act('advance')
            return
        if self.state.mode == 'done':
            return
        if key == 'TAB':
            self.preview = (self.preview + 1) % 3
            return
        if key == 'LEFT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 - 1) % 3
        elif key == 'RIGHT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 + 1) % 3
        elif key in ('UP', 'DOWN'):
            self.cell = (self.cell + 3) % 6
        elif key == 'SPACE':
            self.item = (self.item + 1) % 6
        elif key == 'Z':
            self.act('place:' + str(self.cell) + ',' + ITEMS[self.item])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.rect(0, 0, WIDTH, 43, 2)
        p.text(12, 9, 'NIGHT MUSEUM / STAGE ' + str(STAGE), 10)
        p.text(12, 26, TITLE, 7)
        p.text(490, 9, 'FICTIONAL DISCRETE MODEL / NOT REAL AUDIENCES', 6)
        p.text(490, 26, '4 ITEMS / 6 STANDS / 3 INDEPENDENT VISITORS', 6)
        p.text(12, 53, 'ENTRY (-1,0) -> STAND 1    ARROWS: SELECT STAND', 7)
        p.text(12, 66, 'SELECTED ITEM: ' + ITEMS[self.item] + ' / ' + EN_ITEMS[ITEMS[self.item]], 10)
        tags, links = contexts(s.layout, self.horizontal)
        for cell, it in enumerate(s.layout):
            x, y = 12 + (cell % 3) * 140, 86 + (cell // 3) * 96
            p.rect(x, y, 132, 86, 2)
            p.rectb(x, y, 132, 86, 10 if cell == self.cell else 5)
            p.text(x+7, y+7, 'STAND ' + str(cell+1), 6)
            p.text(x+7, y+26, (it + ' ' + EN_ITEMS[it]) if it else 'EMPTY', 7)
            for row, tag in enumerate(tags[cell]):
                p.text(x+7, y+43+row*10, EN_TAGS[tag], 11)
        count = sum(bool(it) for it in s.layout)
        p.text(12, 280, 'EXHIBITS: ' + str(count) + '/4    LOGICAL TICK: ' + str(s.tick), 10)
        p.text(12, 302, 'CONTEXT: ' + ('HORIZONTAL ONLY' if self.horizontal else 'ORTHOGONAL / NO DIAGONALS'), 6)
        for i, (a, b, tag, story) in enumerate(PAIRS):
            active = any(link[2] == tag for link in links)
            p.text(12, 320+i*15, ('+ ' if active else '. ') + a + '+' + b + ' -> ' + EN_TAGS[tag], 11 if active else 5)
        who = self.preview
        position, budget, seen, learned = (-1, 0), LEVEL.visitors[who].budget, (), ()
        if s.mode == 'run':
            who = s.visitor
            position, budget, seen, learned = s.position, s.budget, s.seen, s.learned
        visitor = LEVEL.visitors[who]
        options = choices(s.layout, visitor, position, budget, seen, learned, self.horizontal)
        p.text(454, 57, ('CURRENT' if hasattr(s, 'mode') and s.mode == 'run' else 'PREVIEW') + ' VISITOR: ' + ('ASA','KANA','MIO')[who], 10)
        p.text(454, 71, 'INTEREST: ' + words(visitor.interests) + '  GOAL: ' + EN_TAGS[visitor.goal], 7)
        p.text(454, 85, 'POSITION ' + str(position) + '  BUDGET ' + str(budget), 6)
        p.text(454, 99, 'KNOWN: ' + words(learned), 6)
        p.text(454, 120, 'ORDER: ATTRACTION DESC, COST ASC, STAND ASC', 10)
        p.text(454, 133, 'NEW INTEREST x2 + OTHER NEW x1; KNOWN = 0', 6)
        for i, choice in enumerate(options):
            y = 153+i*38
            p.text(454, y, str(i+1)+'. STAND '+str(choice.cell+1)+' '+choice.item+'  SCORE '+str(choice.attraction)+'  COST '+str(choice.cost), 11 if i == 0 else 7)
            p.text(466, y+11, '2x: '+words(choice.interest_new), 11)
            p.text(466, y+22, '1x: '+words(tuple(t for t in choice.new if t not in choice.interest_new)), 6)
        if not options:
            p.text(454, 157, 'NO POSITIVE AFFORDABLE UNSEEN CHOICE', 9)
        p.text(454, 326, 'MODE: '+s.mode.upper()+(' / PAUSED' if self.paused else ''), 10)
        p.text(454, 341, 'SPACE commits ONE visit or ONE exit.' if s.mode == 'run' else 'ENTER opens a four-item design.' if s.mode == 'edit' else 'ENTER returns to editing.', 7)
        p.text(454, 356, 'Next visitor starts fresh at entry.', 6)
        for result in s.results:
            p.text(454, 372+result.visitor*12, ('ASA','KANA','MIO')[result.visitor]+': '+('GOAL YES' if result.success else 'GOAL NO')+' / '+words(result.learned), 11 if result.success else 9)
        if s.mode == 'done':
            score = sum(r.success for r in s.results)
            p.text(12, 404, 'GOALS REACHED: '+str(score)+'/3  '+('BASIC GOAL MET' if score >= 2 else 'TRY ANOTHER DESIGN'), 10)
        p.rect(10, 421, 780, 69, 0)
        p.text(18, 427, 'COMMITTED TRACE (visitor / item / old budget -> new / newly learned)', 6)
        for i, visit in enumerate(s.visits[-3:]):
            p.text(18, 443+i*13, ('ASA','KANA','MIO')[visit.visitor]+' / '+visit.item+' / '+str(visit.budget_after+visit.cost)+' -> '+str(visit.budget_after)+' / '+words(visit.new), 7)
        p.text(12, 500, 'STATUS: '+self.reason, 11)
        p.text(12, 516, 'ARROWS STAND | SPACE ITEM / COMMIT | Z PLACE | ENTER OPEN / EDIT | P PAUSE', 7)
        p.text(12, 534, 'ONE KEY AT A TIME. RELEASE ALL KEYS BEFORE NEXT INPUT. ESC QUIT.', 6)

CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R', 'TAB', 'RETURN', 'P')
DEMO_KEYS = ('RETURN', 'SPACE', 'SPACE', 'SPACE')

def main(argv=None):
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args(argv)
    headless = args.headless_smoke or args.demo_checkpoint
    if (args.screenshot or args.report) and not headless:
        parser.error('--screenshot/--report require a headless mode')
    if args.screenshot and Path(args.screenshot).suffix.lower() != '.png':
        parser.error('--screenshot must end in .png')
    if args.report and Path(args.report).suffix.lower() != '.json':
        parser.error('--report must end in .json')
    outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v]
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    # 検査が済むまでPyxelを読み込まない。importとモデル試験は標準ライブラリだけ。
    import pyxel as p
    app = App()
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keymap = {k: getattr(p, 'KEY_'+k) for k in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='068 Night Museum - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            pressed = {sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keymap.items() if p.btnp(v)},
                        {k for k,v in keymap.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage': STAGE, 'kind': 'actual Pyxel headless automated render',
                        'keys': sequence, 'state': asdict(app.state), 'reason': app.reason,
                        'horizontal': app.horizontal, 'os_input_tested': False,
                        'human_play_tested': False, 'audio_tested': False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

if __name__ == '__main__':
    main()
STEP 05 / 05

巡回を丸ごと戻し、同じ入力を再現保存する

Timeline・履歴の単位・比べる条件・UTF-8 の記録・ハッシュと計算し直し。

最後の段階は、元のゲームの状況 C のふつうのモデル・左右だけの比較モデル、step、record、replay と同じ結果を返します。Timeline は state と history を分け、「並べる」「開館する」「編集に戻る」だけを履歴に追加します。advance は履歴を増やさないので、開館中や終わった直後の X は、見学全体を取り消して、開館直前の編集に戻ります。終わったあとに Enter で編集に戻してから X を押すと、直前の終わった状態が戻ります。B は「左右だけのとなり」に切りかえ、最初の配置と入力の記録をリセットします。上下左右と左右だけを比べるときは、同じ配置を作り直してください。S は、起動するときに --save-record で指定した JSON に、レベル・配置のルール・入力の意味・それぞれの状態・ハッシュを書きこみます。同じ名前のファイルがあれば置きかわります。起動しただけでは、保存先のファイルもフォルダーも作りません。配置の途中や見学の途中でも保存でき、保存できたことと、ゲームの目標の達成は別です。replay は、元のゲームの状況 C と同じ数と入力から計算し直し、書きかえられた記録は受け付けません。ハッシュは、署名や本人の証明ではありません。

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

05_replay.py / 注目する部分
def step(t, l, action, horizontal=False):
    if action == 'undo':
        return (Timeline(t.history[-1], t.history[:-1]), 'undo') if t.history else (t, 'no_undo')
    s, why = advance(t.state, l, action, horizontal)
    if s == t.state:
        return (t, why)
    return (Timeline(s, t.history if action == 'advance' else t.history + (t.state,)), why)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_replay.py
macOS / Linux
.venv/bin/python 05_replay.py
画面で見ることEnter → Space → Space → X で、最初の4品の配置に戻り、予算も見学の記録も開館前に戻ります。自動デモは最初の状況 C を最後まで進め、3/3の達成を表示します。B のあとも、最初の状況 C には上下に成り立つ組がないので、最初の1手は同じです。一方 (T,M,N,B,空,空) を作り直すと、上下左右の版は2/3、左右だけの版は1/3です。--save-record を指定して S を押すと、開館前でも JSON を作り、S を押しても logical tick は増えません。
  • 見学中の advance は Undo の履歴に入れない
  • R/B で、記録の始まりを最初に戻す
  • 保存するのは指定した場所だけ。同じ名前なら上書き
  • 途中でも保存できる。保存は勝ちではない
  • 計算し直しとハッシュで確かめる。署名とは呼ばない
つまずきやすいところすべての advance を history に入れると、X で1歩ずつ戻ってしまい、元のゲームの「見学全体の取り消し」とちがってしまいます。B で配置を残したつもりで比べても、実際は最初に戻っているので、別の条件です。JSON の保存ができたことを「3人の目標を達成した」とは呼ばず、途中の final.mode も読んでください。手順や答えを読んだあとのやり直しを、「自分の力でできた」とは言わないようにしましょう。
少し変えてみよう

Enter → Space → Space → X で、開館前に戻します。もう一度 Enter、そのあと Space を9回、Enter → X では、done が元に戻ります。--save-record "出力 記録/巡回.json" を付けて起動し、開館前に S、開館して1歩進めてからもう一度 S を押して、同じファイルの mode が edit から run に変わることを確かめましょう。自動のテストは一時的なフォルダーで動かすので、配っているファイルに記録は書きこみません。fixture の history を replay し、sha256 や settings を書きかえると ValueError になるテストも読んでみてください。

05_replay.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(505行)
05_replay.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""068 小さな博物館の夜・日本語教材 5/5。架空の離散来客モデル。"""
from dataclasses import dataclass, asdict, replace
import hashlib
import json

STAGE = 5
WIDTH, HEIGHT = 800, 560
TITLE = 'UNDO / COMPARE / SAVE'
ITEMS = ('T', 'M', 'B', 'N', 'V', 'L')
NAMES = {'T': '種', 'M': '臼', 'B': '碗', 'N': '網', 'V': '舟', 'L': '鈴'}
BASE = {'T': '育つ', 'M': '道具', 'B': '食', 'N': '海', 'V': '移動', 'L': '音'}
PRESET = ('M', 'B', 'T', 'N', '', '')
EN_ITEMS = {'T':'SEED', 'M':'MILL', 'B':'BOWL', 'N':'NET', 'V':'BOAT', 'L':'BELL'}
EN_TAGS = {'育つ':'GROW', '道具':'TOOL', '食':'FOOD', '海':'SEA', '移動':'MOVE',
           '音':'SOUND', 'くらし':'LIFE', '協力':'COOP', '合図':'SIGNAL', '循環':'CYCLE'}

PAIRS = (('T', 'M', '食', '種を臼でひいて食べ物へ'), ('M', 'B', 'くらし', '臼でひいた粉を碗によそう'), ('N', 'V', '協力', '舟と網で協力した漁'), ('V', 'L', '合図', '舟から鈴で知らせた合図'), ('T', 'B', '循環', '使い終わった碗で種を育てた工夫'))

@dataclass(frozen=True)
class Visitor:
    name: str
    interests: tuple
    goal: str
    budget: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    visitors: tuple
    layout: tuple

LEVEL = Level('068-C-g1', '同じ碗の二つの話', (Visitor('アサ', ('道具', 'くらし'), 'くらし', 5), Visitor('カナ', ('育つ', '循環'), '循環', 5), Visitor('ミオ', ('食', '海'), '食', 7)), PRESET)
LEVELS = (LEVEL,)

@dataclass(frozen=True)
class Choice:
    cell: int
    item: str
    cost: int
    attraction: int
    new: tuple
    interest_new: tuple

@dataclass(frozen=True)
class Visit:
    visitor: int
    cell: int
    item: str
    cost: int
    attraction: int
    new: tuple
    budget_after: int
    learned: tuple

@dataclass(frozen=True)
class Result:
    visitor: int
    learned: tuple
    budget_left: int
    seen: tuple
    success: bool
    reason: str

@dataclass(frozen=True)
class State:
    layout: tuple
    mode: str = 'edit'
    visitor: int = 0
    position: tuple = (-1, 0)
    budget: int = 0
    seen: tuple = ()
    learned: tuple = ()
    visits: tuple = ()
    results: tuple = ()
    tick: int = 0

def initial():
    return State(PRESET)

def xy(cell):
    return (cell % 3, cell // 3)

def adjacent(a, b, horizontal=False):
    x, y = xy(a)
    u, v = xy(b)
    return y == v and abs(x - u) == 1 if horizontal else abs(x - u) + abs(y - v) == 1

def contexts(layout, horizontal=False):
    tags = [set((BASE[it],)) if it else set() for it in layout]
    links = []
    for a in range(6):
        for b in range(a + 1, 6):
            if not adjacent(a, b, horizontal):
                continue
            for x, y, tag, story in PAIRS:
                if set((layout[a], layout[b])) == {x, y}:
                    tags[a].add(tag)
                    tags[b].add(tag)
                    links.append((a, b, tag, story))
    return (tuple((tuple(sorted(t)) for t in tags)), tuple(links))

def choices(layout, p, position, budget, seen, learned, horizontal=False):
    tags, _ = contexts(layout, horizontal)
    options = []
    for cell, it in enumerate(layout):
        if not it or it in seen:
            continue
        x, y = xy(cell)
        cost = abs(x - position[0]) + abs(y - position[1]) + 1
        if cost > budget:
            continue
        new = tuple((t for t in tags[cell] if t not in learned))
        interest = tuple((t for t in new if t in p.interests))
        attraction = len(new) + len(interest)
        if attraction:
            options.append(Choice(cell, it, cost, attraction, new, interest))
    return tuple(sorted(options, key=lambda c: (-c.attraction, c.cost, c.cell)))

def next_choice(s, l, horizontal=False):
    if s.mode != 'run':
        return None
    options = choices(s.layout, l.visitors[s.visitor], s.position, s.budget, s.seen, s.learned, horizontal)
    return options[0] if options else None

def advance(s, l, action, horizontal=False):
    op, sep, arg = action.partition(':')
    if op == 'place' and sep:
        if s.mode != 'edit':
            return (s, 'locked')
        bits = arg.split(',')
        if len(bits) != 2 or not bits[0].isdigit():
            return (s, 'invalid')
        cell = int(bits[0])
        item = bits[1]
        if not 0 <= cell < 6 or item not in ITEMS:
            return (s, 'invalid')
        layout = list(s.layout)
        if layout[cell] == item:
            layout[cell] = ''
        else:
            if item in layout:
                layout[layout.index(item)] = ''
            elif sum((bool(x) for x in layout)) == 4 and (not layout[cell]):
                return (s, 'four_only')
            layout[cell] = item
        return (replace(s, layout=tuple(layout), tick=s.tick + 1), 'placed')
    if action == 'open':
        if s.mode != 'edit':
            return (s, 'locked')
        if sum((bool(x) for x in s.layout)) != 4:
            return (s, 'need_four')
        return (State(s.layout, 'run', 0, (-1, 0), l.visitors[0].budget, tick=s.tick + 1), 'opened')
    if action == 'advance':
        if s.mode != 'run':
            return (s, 'not_open')
        choice = next_choice(s, l, horizontal)
        if choice:
            learned = tuple(sorted(set(s.learned) | set(choice.new)))
            budget = s.budget - choice.cost
            visit = Visit(s.visitor, choice.cell, choice.item, choice.cost, choice.attraction, choice.new, budget, learned)
            return (replace(s, position=xy(choice.cell), budget=budget, seen=s.seen + (choice.item,), learned=learned, visits=s.visits + (visit,), tick=s.tick + 1), 'viewed')
        p = l.visitors[s.visitor]
        possible = [i for i, it in enumerate(s.layout) if it and it not in s.seen]
        affordable = any((abs(xy(i)[0] - s.position[0]) + abs(xy(i)[1] - s.position[1]) + 1 <= s.budget for i in possible))
        reason = '新しい理解が増えない' if affordable else '残り予算では届かない' if possible else '全ての展示を見た'
        result = Result(s.visitor, s.learned, s.budget, s.seen, p.goal in s.learned, reason)
        n = s.visitor + 1
        return (replace(s, mode='done' if n == 3 else 'run', visitor=n, position=(-1, 0), budget=l.visitors[n].budget if n < 3 else 0, seen=(), learned=(), results=s.results + (result,), tick=s.tick + 1), 'closed' if n == 3 else 'next_visitor')
    if action == 'edit':
        if s.mode not in ('run', 'done'):
            return (s, 'not_open')
        return (State(s.layout, tick=s.tick + 1), 'editing')
    return (s, 'unknown')

def forecast(s, l, horizontal=False):
    tags, links = contexts(s.layout, horizontal)
    first = tuple(((choices(s.layout, p, (-1, 0), p.budget, (), (), horizontal) or (None,))[0] for p in l.visitors))
    return {'tags': tags, 'links': links, 'first': first, 'next': next_choice(s, l, horizontal)}

def simulate(l, layout=None, horizontal=False):
    s = State(tuple(layout or l.layout))
    s, _ = advance(s, l, 'open', horizontal)
    if s.mode != 'run':
        raise ValueError('four unique items required')
    while s.mode == 'run':
        s, _ = advance(s, l, 'advance', horizontal)
    return s

def invariant(s, l, horizontal=False):
    its = tuple((x for x in s.layout if x))
    assert len(its) <= 4 and len(set(its)) == len(its) and (set(its) <= set(ITEMS))
    assert 0 <= s.visitor <= 3 and 0 <= s.budget and (s.visitor == len(s.results))
    if s.mode == 'run':
        assert len(its) == 4 and s.budget <= l.visitors[s.visitor].budget
    tags, _ = contexts(s.layout, horizontal)
    for v in s.visits:
        assert set(v.new) <= set(tags[v.cell]) and v.item == s.layout[v.cell]
    return True

VERSION = '068-g1'
SCHEMA = 2

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

def step(t, l, action, horizontal=False):
    if action == 'undo':
        return (Timeline(t.history[-1], t.history[:-1]), 'undo') if t.history else (t, 'no_undo')
    s, why = advance(t.state, l, action, horizontal)
    if s == t.state:
        return (t, why)
    return (Timeline(s, t.history if action == 'advance' else t.history + (t.state,)), why)

def digest(v):
    return hashlib.sha256(json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

def record(l, actions, horizontal=False):
    t = Timeline(initial())
    events = []
    for n, a in enumerate(actions):
        t, why = step(t, l, a, horizontal)
        invariant(t.state, l)
        s = asdict(t.state)
        events.append({'index': n, 'input': a, 'reason': why, 'logical_tick': t.state.tick, 'state': s, 'sha256': digest(s)})
    data = {'level': asdict(l), 'items': ITEMS, 'names': NAMES, 'base': BASE, 'pairs': PAIRS}
    return {'schema': SCHEMA, 'ruleset': VERSION, 'public_id': l.id, 'data': data, 'data_sha256': digest(data), 'settings': {'horizontal': horizontal}, 'inputs': list(actions), 'events': events, 'final': asdict(t.state), 'sha256': digest(asdict(t.state))}

def replay(rec):
    if rec.get('schema') != SCHEMA or rec.get('ruleset') != VERSION:
        raise ValueError('unsupported version')
    l = next((l for l in LEVELS if l.id == rec.get('public_id')), None)
    if l is None:
        raise ValueError('unknown level')
    settings = rec.get('settings')
    if not isinstance(settings, dict) or set(settings) != {'horizontal'} or type(settings['horizontal']) is not bool:
        raise ValueError('invalid settings')
    expected = record(l, rec['inputs'], settings['horizontal'])
    if digest(expected) != digest(rec):
        raise ValueError('record mismatch')
    return expected

def save_record(path, actions, horizontal=False):
    """保存は状態操作ではない。未開館・途中の記録も再現できる。"""
    from pathlib import Path
    target = Path(path).expanduser().resolve()
    data = record(LEVEL, actions, horizontal)
    replay(data)  # 書く前に自己検証。成功・完成を要求しない。
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    return target

def words(tags):
    return ','.join(EN_TAGS[tag] for tag in tags) or '-'

class App:
    def __init__(self, save_path=None):
        self.save_path = save_path
        self.horizontal = False
        self.reset()

    def reset(self):
        self.timeline = Timeline(initial())
        self.cell = 0
        self.item = 0
        self.preview = 0
        self.paused = False
        self.gated = False
        self.actions = []
        self.reason = 'ready'
        self.last_save = None

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

    def act(self, action):
        self.timeline, self.reason = step(self.timeline, LEVEL, action, self.horizontal)
        self.actions.append(action)

    def control(self, pressed, held):
        # 教材の入力: 1キーずつ全解放。原作の同時押し優先順位は再現しない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.reason = 'release_all'
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key not in CONTROL_KEYS:
            return
        self.gated = True
        if key == 'R':
            self.reset()
            self.gated = True
            return
        if key == 'B':
            self.horizontal = not self.horizontal
            self.reset()
            self.gated = True
            return
        if key == 'S':
            if not self.save_path:
                self.reason = 'use_save_record_option'
            else:
                try:
                    self.last_save = str(save_record(self.save_path, self.actions, self.horizontal))
                    self.reason = 'saved'
                except OSError:
                    self.reason = 'save_failed'
            return
        if key == 'X':
            self.act('undo')
            return
        if key == 'P':
            self.paused = not self.paused
            return
        if self.paused:
            return
        if key == 'RETURN':
            self.act('open' if self.state.mode == 'edit' else 'edit')
            return
        if self.state.mode == 'run':
            if key == 'SPACE':
                self.act('advance')
            return
        if self.state.mode == 'done':
            return
        if key == 'TAB':
            self.preview = (self.preview + 1) % 3
            return
        if key == 'LEFT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 - 1) % 3
        elif key == 'RIGHT':
            self.cell = (self.cell // 3) * 3 + (self.cell % 3 + 1) % 3
        elif key in ('UP', 'DOWN'):
            self.cell = (self.cell + 3) % 6
        elif key == 'SPACE':
            self.item = (self.item + 1) % 6
        elif key == 'Z':
            self.act('place:' + str(self.cell) + ',' + ITEMS[self.item])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.rect(0, 0, WIDTH, 43, 2)
        p.text(12, 9, 'NIGHT MUSEUM / STAGE ' + str(STAGE), 10)
        p.text(12, 26, TITLE, 7)
        p.text(490, 9, 'FICTIONAL DISCRETE MODEL / NOT REAL AUDIENCES', 6)
        p.text(490, 26, '4 ITEMS / 6 STANDS / 3 INDEPENDENT VISITORS', 6)
        p.text(12, 53, 'ENTRY (-1,0) -> STAND 1    ARROWS: SELECT STAND', 7)
        p.text(12, 66, 'SELECTED ITEM: ' + ITEMS[self.item] + ' / ' + EN_ITEMS[ITEMS[self.item]], 10)
        tags, links = contexts(s.layout, self.horizontal)
        for cell, it in enumerate(s.layout):
            x, y = 12 + (cell % 3) * 140, 86 + (cell // 3) * 96
            p.rect(x, y, 132, 86, 2)
            p.rectb(x, y, 132, 86, 10 if cell == self.cell else 5)
            p.text(x+7, y+7, 'STAND ' + str(cell+1), 6)
            p.text(x+7, y+26, (it + ' ' + EN_ITEMS[it]) if it else 'EMPTY', 7)
            for row, tag in enumerate(tags[cell]):
                p.text(x+7, y+43+row*10, EN_TAGS[tag], 11)
        count = sum(bool(it) for it in s.layout)
        p.text(12, 280, 'EXHIBITS: ' + str(count) + '/4    LOGICAL TICK: ' + str(s.tick), 10)
        p.text(12, 302, 'CONTEXT: ' + ('HORIZONTAL ONLY' if self.horizontal else 'ORTHOGONAL / NO DIAGONALS'), 6)
        for i, (a, b, tag, story) in enumerate(PAIRS):
            active = any(link[2] == tag for link in links)
            p.text(12, 320+i*15, ('+ ' if active else '. ') + a + '+' + b + ' -> ' + EN_TAGS[tag], 11 if active else 5)
        who = self.preview
        position, budget, seen, learned = (-1, 0), LEVEL.visitors[who].budget, (), ()
        if s.mode == 'run':
            who = s.visitor
            position, budget, seen, learned = s.position, s.budget, s.seen, s.learned
        visitor = LEVEL.visitors[who]
        options = choices(s.layout, visitor, position, budget, seen, learned, self.horizontal)
        p.text(454, 57, ('CURRENT' if hasattr(s, 'mode') and s.mode == 'run' else 'PREVIEW') + ' VISITOR: ' + ('ASA','KANA','MIO')[who], 10)
        p.text(454, 71, 'INTEREST: ' + words(visitor.interests) + '  GOAL: ' + EN_TAGS[visitor.goal], 7)
        p.text(454, 85, 'POSITION ' + str(position) + '  BUDGET ' + str(budget), 6)
        p.text(454, 99, 'KNOWN: ' + words(learned), 6)
        p.text(454, 120, 'ORDER: ATTRACTION DESC, COST ASC, STAND ASC', 10)
        p.text(454, 133, 'NEW INTEREST x2 + OTHER NEW x1; KNOWN = 0', 6)
        for i, choice in enumerate(options):
            y = 153+i*38
            p.text(454, y, str(i+1)+'. STAND '+str(choice.cell+1)+' '+choice.item+'  SCORE '+str(choice.attraction)+'  COST '+str(choice.cost), 11 if i == 0 else 7)
            p.text(466, y+11, '2x: '+words(choice.interest_new), 11)
            p.text(466, y+22, '1x: '+words(tuple(t for t in choice.new if t not in choice.interest_new)), 6)
        if not options:
            p.text(454, 157, 'NO POSITIVE AFFORDABLE UNSEEN CHOICE', 9)
        p.text(454, 326, 'MODE: '+s.mode.upper()+(' / PAUSED' if self.paused else ''), 10)
        p.text(454, 341, 'SPACE commits ONE visit or ONE exit.' if s.mode == 'run' else 'ENTER opens a four-item design.' if s.mode == 'edit' else 'ENTER returns to editing.', 7)
        p.text(454, 356, 'Next visitor starts fresh at entry.', 6)
        for result in s.results:
            p.text(454, 372+result.visitor*12, ('ASA','KANA','MIO')[result.visitor]+': '+('GOAL YES' if result.success else 'GOAL NO')+' / '+words(result.learned), 11 if result.success else 9)
        if s.mode == 'done':
            score = sum(r.success for r in s.results)
            p.text(12, 404, 'GOALS REACHED: '+str(score)+'/3  '+('BASIC GOAL MET' if score >= 2 else 'TRY ANOTHER DESIGN'), 10)
        p.rect(10, 421, 780, 69, 0)
        p.text(18, 427, 'COMMITTED TRACE (visitor / item / old budget -> new / newly learned)', 6)
        for i, visit in enumerate(s.visits[-3:]):
            p.text(18, 443+i*13, ('ASA','KANA','MIO')[visit.visitor]+' / '+visit.item+' / '+str(visit.budget_after+visit.cost)+' -> '+str(visit.budget_after)+' / '+words(visit.new), 7)
        p.text(12, 500, 'STATUS: '+self.reason, 11)
        p.text(12, 516, 'ARROWS STAND | SPACE ITEM / COMMIT | Z PLACE | ENTER OPEN / EDIT | P PAUSE', 7)
        p.text(12, 534, 'X UNDO | B COMPARE+RESET | S SAVE TO --save-record PATH | R RESET | ESC QUIT', 6)

CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R', 'TAB', 'RETURN', 'P', 'X', 'B', 'S')
DEMO_KEYS = ('RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE')

def main(argv=None):
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    parser.add_argument('--save-record', help='S writes this path; existing file is replaced')
    args = parser.parse_args(argv)
    headless = args.headless_smoke or args.demo_checkpoint
    if (args.screenshot or args.report) and not headless:
        parser.error('--screenshot/--report require a headless mode')
    if args.screenshot and Path(args.screenshot).suffix.lower() != '.png':
        parser.error('--screenshot must end in .png')
    if args.report and Path(args.report).suffix.lower() != '.json':
        parser.error('--report must end in .json')
    if args.save_record and Path(args.save_record).suffix.lower() != '.json':
        parser.error('--save-record must end in .json')
    outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v]
    if args.save_record:
        outputs.append(Path(args.save_record).expanduser().resolve())
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    # 検査が済むまでPyxelを読み込まない。importとモデル試験は標準ライブラリだけ。
    import pyxel as p
    app = App(args.save_record)
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keymap = {k: getattr(p, 'KEY_'+k) for k in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='068 Night Museum - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            pressed = {sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keymap.items() if p.btnp(v)},
                        {k for k,v in keymap.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().resolve()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage': STAGE, 'kind': 'actual Pyxel headless automated render',
                        'keys': sequence, 'state': asdict(app.state), 'reason': app.reason,
                        'horizontal': app.horizontal, 'os_input_tested': False,
                        'human_play_tested': False, 'audio_tested': False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

if __name__ == '__main__':
    main()
AFTER STEP 05

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

元のゲームの状況 C(068-C-g1)です。2行3列の台に、6つの品から4つを並べます。上下左右にとなり合った決まった組で、両方の品に意味のタグが付きます。まだ知らない興味のタグは2点、ほかの新しいタグは1点、費用は「上下左右の距離+1」です。見たい度合いがいちばん大きい展示へ行き、同点なら費用、次に台の番号で決めます。3人は別々に動き、3人のうち2人が目標のタグを知れば基本の達成です。4品より少ない編集は正しい状態ですが、開館には4品が必要です。

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

比べる点5ステップの教材元の完成版
範囲状況 C だけを使います。01 は並べる、02 でとなり合い、03 で予告、04 で見学、05 で元のゲームの状況 C の履歴・比較・記録のモデルです。A・B・C の3つの状況を 1/2/3 で選べます。
入力と時間表紙はありません。Space 1回で、見るか帰るかを1つ確定します。どの操作でも全部のキーを離すまで待ち、同時押しは受け付けません。入力の制御全体が元のゲームと同じというわけではありません。表紙があります。42フレームごとに自動で進み、決まったキーの優先順位と、場面が変わるときの「全部離すまで待つ」処理があります。
表示800×560、文字(ASCII)と図形です。候補の順位と、2点・1点の内わけ、確定したときの「前の予算 → 新しい予算」を表示します。日本語の説明は本文にあります。800×620、日本語の文字、絵、最初の1手の予告、一時停止中の次の候補の内わけがあります。
記録05 だけ、S で --save-record に指定した場所に UTF-8 で保存し、同じ名前なら上書きします。起動しただけでは作りません。画面を出さないテストの報告は描画の記録で、replay 用の記録とは別のものです。S で、ソースの近くの records/ に、セッション名とフレーム名を付けて保存します。F12 と、自動の GIF・イベントの記録があります。
省略A/B の選択、表紙、M、F12、音、GIF、元のゲームの solver の画面への組みこみは省いています。人が理解できるか・おもしろいか、Windows/macOS の実際のパソコン、OS のキー入力、音は確かめていません。A・B・C の限られた問題、日本語のフォント、メニュー、実際の画面の保存、solver などがあります。
権利Noto の文字は使いませんが、元のゲームの MIT の全文を、すべてのソースに入れています。参照している Noto/Pyxel の権利の文書の全文も、そのまま入れています。Noto をもとにした文字は SIL OFL、コードは MIT です。

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

  1. 元のゲームの状況 A・B を加えて、状況ごとの保存と切りかえをテストしてみましょう。
  2. 元のゲームの「42フレームごとの更新」と、一時停止・再開のときの入力の待ち方を移して、確かめてみましょう。
  3. 同じ配置を2つのルールで並べて、タグが増えたところと、変わった道すじを見えるようにしてみましょう。
  4. 全部で5,400通りの配置について、候補と成功の数を調べてみましょう。ただし、本物の来館者にそのまま当てはめないでください。
  5. 別の作品として、好みや費用のルールを変えるときは、ruleset を分けましょう。
自分のゲームに変えるなら

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

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