探索・アドベンチャーLESSON 75架空の人物条件と並行準備の計画パズル原作086

ひと部屋の災害会議の作り方

計画は戻せる。でも、聞いた事情は覚えておく。
架空の3つの家族の希望と、引き受けられる条件(受入式)をデータで表し、同時に進む準備と、2回に分けて出る船の時計、聞いた情報を残したまま戻せる Undo を作ります。

完成版の遊び方必ず全員が高台へ着く架空の町で、3つの家族の事情を聞き、どの船で行くかと準備する設備を選ぶ、人物たちの物語のゲームです。

ひと部屋の災害会議の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

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

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

完成版の主な操作:左右人物/Z質問、Tab計画/上下欄/Z設定、Enter提出確認、Space時計、X戻す

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

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

うまく表示されないとき

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

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

計画の戻し方と、知った情報の戻し方を分ける。

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

1. 計画のデータと、表示用のカーソル
2. AND・OR と、人物が口にした希望
3. 「もう知っている」という印と、計画とは別に持つ情報
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple と書き換えない dataclass/replace / ビット演算と再帰(関数が自分を呼ぶこと)は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:人物を選ぶ / 上下:後便・灯り・台車・戸締りの編集欄 / Z:選んだ後便/設備を確定 / H:03以降、事情を聞く / Enter:04以降、一回で計画提出 / Space:04以降、提出した時計を一刻進める / V:04以降、提出から編集へ戻す / X:05のみ一StateずつUndo / R:既知情報だけ残して最初へ / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_plan.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一人を後便、三つの設備をビットへ

計画のデータと、表示用のカーソル。

このゲームには、ミナ・コウ・ハルの3つの家族がいます。1家族だけを後の船(後便)に選ぶと、残りの2家族が最初の船(初便)です。最初は、ハルが後便で、設備はありません。架空の町の人物たちの物語で、現実の避難計画や防災のアドバイスを作る教材ではありません。

左右キーで人物を、上下キーで編集する欄を選び、Z で確定します。LATE の欄なら、今選んでいる人物を後便にします。LIGHT(灯り)・CART(台車)・LOCK(戸締り)なら、その設備のあり・なしを切り替えます。矢印キーだけでは計画は変わりません。人物のカーソルと編集の欄は App に、確定した船と設備は State に、分けて持ちます。

設備は3けたのビットの整数で表します。灯りが1、台車が2、戸締りが4 です。設備の番号 i に対応する 1<<i と XOR(排他的論理和)を取ると、1つだけを切り替えられます。cost は、ビットが立っている設備の費用を足し合わせます。ビットの重み 1・2・4 と、費用 1・2・1 は別のものです。戸締りの値4を、費用4と勘違いしないようにしましょう。

予算は4なので、元の3つの設備を全部買っても予算内です。ここではまだ時間も提出もなく、計画の編集だけを単独で確かめます。あとで予算を変える練習でも、編集の途中で予算を超えたことは表示するだけで、提出のときの検査を入れるのは 04 です。

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

01_plan.py / 注目する部分
        return replace(state,equipment=state.equipment^(1<<value))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_plan.py
macOS / Linux
.venv/bin/python 01_plan.py
画面で見ること下 → 下 → Z で CART、下 → Z で LOCK を選びます。設備の mask は6、SPENT 3、SPARE 1 です。ハルは LATE のままで、ほかの2人は FIRST です。
  • 後便の1家族を決めれば、初便の2家族も決まる
  • カーソルと、確定した状態を分ける
  • 設備のビットと、費用を分ける
つまずきやすいところ3けたのビットの数値は、買った金額ではありません。mask 6 は台車と戸締りで、費用は 2+1=3 です。
少し変えてみよう

DATA の最初の A にある 'budget': 4 だけを2に変えて、同じ操作をしてみましょう。SPENT 3、SPARE -1 と表示されます。この段階は編集だけなので、まだ「提出を断る」という操作はありません。確認したら4に戻します。

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

"""ひと部屋の災害会議・教材1/5。架空の人物の有限模型。現実の防災助言ではありません。"""
from dataclasses import dataclass,replace,asdict
COSTS = (1, 2, 1)

@dataclass(frozen=True)
class Person:
    name: str
    intro: str
    wish: tuple
    accept: tuple
    story: str

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    budget: int
    people: tuple

def tree(v):
    return tuple((tree(x) for x in v)) if isinstance(v, list) else v

DATA = [{'id': 'A', 'title': '暗くなる前', 'budget': 4, 'people': [{'name': 'ミナ', 'intro': '早めに船へ乗りたい世帯', 'wish': ['first', 0], 'accept': ['or', ['first', 0], ['item', 0]], 'story': '暗い待合が心配。灯りがあれば後便でも大丈夫。'}, {'name': 'コウ', 'intro': '川沿いで店を営む世帯', 'wish': ['item', 2], 'accept': ['and', ['item', 2], ['item', 1]], 'story': '戸締りだけでなく、台車で店の品も運びたい。'}, {'name': 'ハル', 'intro': '知り合いの隣が安心な世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 0], ['late', 2]]], 'story': 'ミナが初便なら、自分は後便で見送りを引き受ける。'}]}, {'id': 'B', 'title': '荷物の多い朝', 'budget': 4, 'people': [{'name': 'ソラ', 'intro': '荷物をゆっくり運びたい世帯', 'wish': ['item', 1], 'accept': ['or', ['item', 1], ['and', ['late', 0], ['item', 0]]], 'story': '台車がなくても、灯りの下で後便まで準備できる。'}, {'name': 'ナギ', 'intro': '先発で店を離れる世帯', 'wish': ['first', 1], 'accept': ['and', ['first', 1], ['item', 2]], 'story': '初便がよい。店の戸締りも済ませてから出たい。'}, {'name': 'トワ', 'intro': 'ソラの荷物を気にかける世帯', 'wish': ['same', 0, 2], 'accept': ['or', ['same', 0, 2], ['item', 1]], 'story': 'ソラと別の便でも、台車があるなら安心できる。'}]}, {'id': 'C', 'title': '最後の見送り', 'budget': 4, 'people': [{'name': 'ユズ', 'intro': '最後を見送ってから行く世帯', 'wish': ['late', 0], 'accept': ['and', ['late', 0], ['item', 0]], 'story': '後便で見送りたい。待合の灯りも必要。'}, {'name': 'レン', 'intro': '店の戸を気にする世帯', 'wish': ['item', 2], 'accept': ['or', ['item', 2], ['and', ['first', 1], ['item', 1]]], 'story': '戸締りか、初便で台車と品を運ぶ方法ならよい。'}, {'name': 'アオ', 'intro': 'レンとの同行を考える世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 2], ['late', 0]]], 'story': '別便でも、自分が初便でユズが見送るならよい。'}]}]
BOARDS = tuple((Board(d['id'], d['title'], d['budget'], tuple((Person(p['name'], p['intro'], tree(p['wish']), tree(p['accept']), p['story']) for p in d['people']))) for d in DATA))

@dataclass(frozen=True)
class State:
    late: int = 2
    equipment: int = 0

def initial(b):
    return State()

def cost(mask):
    return sum((c for i, c in enumerate(COSTS) if mask >> i & 1))

def edit(board,state,kind,value):
    if kind=='late' and value in range(3):
        return replace(state,late=value)
    if kind=='item' and value in range(3):
        return replace(state,equipment=state.equipment^(1<<value))
    return state

STAGE = 1
BOARD = BOARDS[0]
LABELS = ("MINA","KOU","HARU")
ITEM_LABELS = ("LIGHT","CART","LOCK")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=initial(BOARD)
        self.person=0
        self.field=0
        self.gated=False
        self.message='READY'

    def commit(self,kind,value=0):
        old=self.state
        self.state=edit(BOARD,self.state,kind,value)
        self.message=kind.upper() if self.state!=old else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key in ('LEFT','RIGHT'):
            self.person=(self.person+(-1 if key=='LEFT' else 1))%3
        elif key in ('UP','DOWN'):
            self.field=(self.field+(-1 if key=='UP' else 1))%4
        elif key=='Z':
            self.commit('late',self.person) if self.field==0 else self.commit('item',self.field-1)

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,10,'ROOM COUNCIL / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'FICTIONAL TOY / NOT REAL EMERGENCY PLANNING',6)
        p.text(12,44,'SPENT '+str(cost(s.equipment))+' / BUDGET '+str(BOARD.budget)+' / SPARE '+str(BOARD.budget-cost(s.equipment)),11 if cost(s.equipment)<=BOARD.budget else 8)
        for i,name in enumerate(LABELS):
            y=75+i*68
            p.rectb(10,y-7,576,61,10 if i==self.person else 5)
            p.text(18,y,name+(' : LATE BOAT' if i==s.late else ' : FIRST BOAT'),7)
        values=[LABELS[s.late]]+[('ON' if s.equipment>>i&1 else 'OFF') for i in range(3)]
        for i,name in enumerate(('LATE',)+ITEM_LABELS):
            x=12+i*145
            p.rectb(x,285,138,27,10 if i==self.field else 5)
            p.text(x+5,295,name+' '+values[i],7)
        p.text(12,416,self.message,10)
        p.text(12,438,'LEFT/RIGHT: PERSON / UP/DOWN: FIELD / Z: CHANGE / R: RESET',7)

def make_demo_keys():
    return ('DOWN','DOWN','Z','DOWN','Z')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,500,title='Room council lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'H':p.KEY_H,'V':p.KEY_V}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                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 keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

希望を、小さな式として読む

AND・OR と、人物が口にした希望。

人物ごとの希望を、関数の if にそれぞれ書きこむのではなく、短い「式」のデータにします。first なら初便、late なら後便、item なら設備、same なら2人が同じ船、という意味です。and と or は、中の式を2つ評価します。evaluate はこの小さな式だけを読む関数で、自由な日本語を理解する AI ではありません。

first は「後便に選ばれた番号と違うか」を調べます。same は、2人それぞれが後便かどうかの True/False を比べます。後便は1家族だけなので、別々の2人が同じ船になるのは、2人とも初便の場合です。これは船の実際の位置ではなく、計画の上での割り当てを比べています。

状況 A では、ミナは初便、コウは戸締り、ハルはコウと同じ船を、口にした希望(公開の希望)にしています。ハルを後便、戸締りだけにすると、画面の WISH は True・True・False です。まだ事情を聞く操作はありません。この True/False は、口にした希望がその計画でかなうかどうかだけを表します。

口にした希望と、詳しい事情をふまえた「引き受けられる条件」は、別のデータです。希望がかなわなくても、別のやり方なら協力できることもあれば、希望がかなっても、追加の準備が必要なこともあります。次の段階でこの違いを具体的に表示し、単純な「好感度」の上がり下がりにまとめないようにします。

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

02_wishes.py / 注目する部分
    if op == 'same':
        return (late == expr[1]) == (late == expr[2])

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_wishes.py
macOS / Linux
.venv/bin/python 02_wishes.py
画面で見ること下 → 下 → 下 → Z で LOCK だけにします。ハルは後便のままで、WISH は MINA=True、KOU=True、HARU=False。SPENT 1、SPARE 3 です。
  • 小さな式を、再帰で読む
  • 同じ船かどうかを、割り当ての True/False で比べる
  • 口にした希望と、引き受けられる条件を分ける
つまずきやすいところWISH=True を、「準備が一番早く終わる」や「事情もかなう」と同じだと思わないでください。口にした希望と、引き受けられる条件の式は、別に扱います。
少し変えてみよう

DATA の A のミナにある 'wish': ['first', 0] だけを ['late', 0] に変えてみましょう。最初のハルが後便の状態では、ミナの WISH は False、人物ミナで LATE の欄の Z を押すと True になります。accept は変えていないので、口にした希望だけの変更です。確認したら first に戻します。

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

"""ひと部屋の災害会議・教材2/5。架空の人物の有限模型。現実の防災助言ではありません。"""
from dataclasses import dataclass,replace,asdict
COSTS = (1, 2, 1)

@dataclass(frozen=True)
class Person:
    name: str
    intro: str
    wish: tuple
    accept: tuple
    story: str

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    budget: int
    people: tuple

def tree(v):
    return tuple((tree(x) for x in v)) if isinstance(v, list) else v

DATA = [{'id': 'A', 'title': '暗くなる前', 'budget': 4, 'people': [{'name': 'ミナ', 'intro': '早めに船へ乗りたい世帯', 'wish': ['first', 0], 'accept': ['or', ['first', 0], ['item', 0]], 'story': '暗い待合が心配。灯りがあれば後便でも大丈夫。'}, {'name': 'コウ', 'intro': '川沿いで店を営む世帯', 'wish': ['item', 2], 'accept': ['and', ['item', 2], ['item', 1]], 'story': '戸締りだけでなく、台車で店の品も運びたい。'}, {'name': 'ハル', 'intro': '知り合いの隣が安心な世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 0], ['late', 2]]], 'story': 'ミナが初便なら、自分は後便で見送りを引き受ける。'}]}, {'id': 'B', 'title': '荷物の多い朝', 'budget': 4, 'people': [{'name': 'ソラ', 'intro': '荷物をゆっくり運びたい世帯', 'wish': ['item', 1], 'accept': ['or', ['item', 1], ['and', ['late', 0], ['item', 0]]], 'story': '台車がなくても、灯りの下で後便まで準備できる。'}, {'name': 'ナギ', 'intro': '先発で店を離れる世帯', 'wish': ['first', 1], 'accept': ['and', ['first', 1], ['item', 2]], 'story': '初便がよい。店の戸締りも済ませてから出たい。'}, {'name': 'トワ', 'intro': 'ソラの荷物を気にかける世帯', 'wish': ['same', 0, 2], 'accept': ['or', ['same', 0, 2], ['item', 1]], 'story': 'ソラと別の便でも、台車があるなら安心できる。'}]}, {'id': 'C', 'title': '最後の見送り', 'budget': 4, 'people': [{'name': 'ユズ', 'intro': '最後を見送ってから行く世帯', 'wish': ['late', 0], 'accept': ['and', ['late', 0], ['item', 0]], 'story': '後便で見送りたい。待合の灯りも必要。'}, {'name': 'レン', 'intro': '店の戸を気にする世帯', 'wish': ['item', 2], 'accept': ['or', ['item', 2], ['and', ['first', 1], ['item', 1]]], 'story': '戸締りか、初便で台車と品を運ぶ方法ならよい。'}, {'name': 'アオ', 'intro': 'レンとの同行を考える世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 2], ['late', 0]]], 'story': '別便でも、自分が初便でユズが見送るならよい。'}]}]
BOARDS = tuple((Board(d['id'], d['title'], d['budget'], tuple((Person(p['name'], p['intro'], tree(p['wish']), tree(p['accept']), p['story']) for p in d['people']))) for d in DATA))

@dataclass(frozen=True)
class State:
    late: int = 2
    equipment: int = 0

def initial(b):
    return State()

def cost(mask):
    return sum((c for i, c in enumerate(COSTS) if mask >> i & 1))

def evaluate(expr, late, mask):
    op = expr[0]
    if op == 'first':
        return late != expr[1]
    if op == 'late':
        return late == expr[1]
    if op == 'same':
        return (late == expr[1]) == (late == expr[2])
    if op == 'item':
        return bool(mask >> expr[1] & 1)
    if op == 'and':
        return evaluate(expr[1], late, mask) and evaluate(expr[2], late, mask)
    if op == 'or':
        return evaluate(expr[1], late, mask) or evaluate(expr[2], late, mask)
    raise ValueError('Unknown expression')

def edit(board,state,kind,value):
    if kind=='late' and value in range(3):
        return replace(state,late=value)
    if kind=='item' and value in range(3):
        return replace(state,equipment=state.equipment^(1<<value))
    return state

STAGE = 2
BOARD = BOARDS[0]
LABELS = ("MINA","KOU","HARU")
ITEM_LABELS = ("LIGHT","CART","LOCK")

def formula(expr):
    op=expr[0]
    if op=='first':return LABELS[expr[1]]+' FIRST'
    if op=='late':return LABELS[expr[1]]+' LATE'
    if op=='same':return LABELS[expr[1]]+' SAME '+LABELS[expr[2]]
    if op=='item':return ITEM_LABELS[expr[1]]
    return '('+formula(expr[1])+(' & ' if op=='and' else ' | ')+formula(expr[2])+')'

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=initial(BOARD)
        self.person=0
        self.field=0
        self.gated=False
        self.message='READY'

    def commit(self,kind,value=0):
        old=self.state
        self.state=edit(BOARD,self.state,kind,value)
        self.message=kind.upper() if self.state!=old else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key in ('LEFT','RIGHT'):
            self.person=(self.person+(-1 if key=='LEFT' else 1))%3
        elif key in ('UP','DOWN'):
            self.field=(self.field+(-1 if key=='UP' else 1))%4
        elif key=='Z':
            self.commit('late',self.person) if self.field==0 else self.commit('item',self.field-1)

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,10,'ROOM COUNCIL / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'FICTIONAL TOY / NOT REAL EMERGENCY PLANNING',6)
        p.text(12,44,'SPENT '+str(cost(s.equipment))+' / BUDGET '+str(BOARD.budget)+' / SPARE '+str(BOARD.budget-cost(s.equipment)),11 if cost(s.equipment)<=BOARD.budget else 8)
        for i,name in enumerate(LABELS):
            y=75+i*68
            p.rectb(10,y-7,576,61,10 if i==self.person else 5)
            p.text(18,y,name+(' : LATE BOAT' if i==s.late else ' : FIRST BOAT'),7)
            p.text(18,y+15,'WISH '+formula(BOARD.people[i].wish)+' = '+str(evaluate(BOARD.people[i].wish,s.late,s.equipment)),6)
        values=[LABELS[s.late]]+[('ON' if s.equipment>>i&1 else 'OFF') for i in range(3)]
        for i,name in enumerate(('LATE',)+ITEM_LABELS):
            x=12+i*145
            p.rectb(x,285,138,27,10 if i==self.field else 5)
            p.text(x+5,295,name+' '+values[i],7)
        p.text(12,416,self.message,10)
        p.text(12,438,'LEFT/RIGHT: PERSON / UP/DOWN: FIELD / Z: CHANGE / R: RESET',7)

def make_demo_keys():
    return ('DOWN','DOWN','DOWN','Z')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,500,title='Room council lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'H':p.KEY_H,'V':p.KEY_V}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                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 keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

聞いた事情だけを、画面に出す

「もう知っている」という印と、計画とは別に持つ情報。

H で、選んだ人物の事情を聞きます。最初は3回質問できます。heard は聞いた事情、asked は今回質問した記録です。同じ事情をもう一度聞いても、質問の回数は減りません。質問しても、計画の設備や船は変わらず、架空の「引き受けられる条件」を知るだけです。話しかけた回数で好感度が上がる仕組みではありません。

引き受けられる条件を満たせば、準備は1刻(時間の区切り1つ)。満たさなくても、口にした希望を満たせば2刻、どちらでもなければ3刻です。引き受けられる条件を先に調べます。たとえば状況 A で、ハルが後便・戸締りだけなら、希望は (True,True,False) でも、準備は (1,2,1) です。コウは台車も必要で、ハルには「ミナが初便なら後便を引き受けられる」という事情があります。

画面には、heard のその人のビットが立っている(もう聞いた)人物だけ、ACCEPT の式と READY の正確な数を表示します。まだ聞いていない人物には式を出さず、READY 1..3 と表示します。ゲームの中に全員の式があることと、遊ぶ画面でもう聞いたことは別です。ただし、ソースコードを読む人から式を隠すための、セキュリティの仕組みではありません。

R で計画を最初に戻しても、聞いた事情は残します。質問は3回、asked は0、設備はなしに戻ります。もう聞いた人物には、H を押しても質問の回数を使いません。この段階はまだ船を動かさず、引き受けられる条件と情報の表示だけを確かめます。

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

03_listen.py / 注目する部分
        if state.heard&bit or state.questions<=0:
            return state

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_listen.py
macOS / Linux
.venv/bin/python 03_listen.py
画面で見ることH → 右 → H → 右 → H で、3人全員の事情を聞きます。下 → 下 → Z → 下 → Z で台車と戸締りを選ぶと、QUESTIONS 0、HEARD MASK 7、全員 READY 1 です。
  • 質問で変わるのは、知っている情報だけ
  • 「もう聞いた」印を見て、表示を選ぶ
  • 引き受けられる条件 → 希望、の順に準備の刻を決める
つまずきやすいところ質問するたびに、設備や人物の条件を変えないでください。また、まだ聞いていない人物の READY を、結果の outcome から直接描くと、聞く前の情報まで見せてしまいます。
少し変えてみよう

State の questions: int = 3 だけを1に変えてみましょう。H でミナに聞いたあと、右 → H でコウを選んでも質問できず、HEARD MASK 1・QUESTIONS 0 です。確認したら3に戻します。これは質問の回数を減らす、ゲームの設計の実験です。

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

"""ひと部屋の災害会議・教材3/5。架空の人物の有限模型。現実の防災助言ではありません。"""
from dataclasses import dataclass,replace,asdict
COSTS = (1, 2, 1)

@dataclass(frozen=True)
class Person:
    name: str
    intro: str
    wish: tuple
    accept: tuple
    story: str

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    budget: int
    people: tuple

def tree(v):
    return tuple((tree(x) for x in v)) if isinstance(v, list) else v

DATA = [{'id': 'A', 'title': '暗くなる前', 'budget': 4, 'people': [{'name': 'ミナ', 'intro': '早めに船へ乗りたい世帯', 'wish': ['first', 0], 'accept': ['or', ['first', 0], ['item', 0]], 'story': '暗い待合が心配。灯りがあれば後便でも大丈夫。'}, {'name': 'コウ', 'intro': '川沿いで店を営む世帯', 'wish': ['item', 2], 'accept': ['and', ['item', 2], ['item', 1]], 'story': '戸締りだけでなく、台車で店の品も運びたい。'}, {'name': 'ハル', 'intro': '知り合いの隣が安心な世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 0], ['late', 2]]], 'story': 'ミナが初便なら、自分は後便で見送りを引き受ける。'}]}, {'id': 'B', 'title': '荷物の多い朝', 'budget': 4, 'people': [{'name': 'ソラ', 'intro': '荷物をゆっくり運びたい世帯', 'wish': ['item', 1], 'accept': ['or', ['item', 1], ['and', ['late', 0], ['item', 0]]], 'story': '台車がなくても、灯りの下で後便まで準備できる。'}, {'name': 'ナギ', 'intro': '先発で店を離れる世帯', 'wish': ['first', 1], 'accept': ['and', ['first', 1], ['item', 2]], 'story': '初便がよい。店の戸締りも済ませてから出たい。'}, {'name': 'トワ', 'intro': 'ソラの荷物を気にかける世帯', 'wish': ['same', 0, 2], 'accept': ['or', ['same', 0, 2], ['item', 1]], 'story': 'ソラと別の便でも、台車があるなら安心できる。'}]}, {'id': 'C', 'title': '最後の見送り', 'budget': 4, 'people': [{'name': 'ユズ', 'intro': '最後を見送ってから行く世帯', 'wish': ['late', 0], 'accept': ['and', ['late', 0], ['item', 0]], 'story': '後便で見送りたい。待合の灯りも必要。'}, {'name': 'レン', 'intro': '店の戸を気にする世帯', 'wish': ['item', 2], 'accept': ['or', ['item', 2], ['and', ['first', 1], ['item', 1]]], 'story': '戸締りか、初便で台車と品を運ぶ方法ならよい。'}, {'name': 'アオ', 'intro': 'レンとの同行を考える世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 2], ['late', 0]]], 'story': '別便でも、自分が初便でユズが見送るならよい。'}]}]
BOARDS = tuple((Board(d['id'], d['title'], d['budget'], tuple((Person(p['name'], p['intro'], tree(p['wish']), tree(p['accept']), p['story']) for p in d['people']))) for d in DATA))

@dataclass(frozen=True)
class State:
    late: int = 2
    equipment: int = 0
    asked: int = 0
    heard: int = 0
    questions: int = 3

def initial(b):
    return State()

def cost(mask):
    return sum((c for i, c in enumerate(COSTS) if mask >> i & 1))

def evaluate(expr, late, mask):
    op = expr[0]
    if op == 'first':
        return late != expr[1]
    if op == 'late':
        return late == expr[1]
    if op == 'same':
        return (late == expr[1]) == (late == expr[2])
    if op == 'item':
        return bool(mask >> expr[1] & 1)
    if op == 'and':
        return evaluate(expr[1], late, mask) and evaluate(expr[2], late, mask)
    if op == 'or':
        return evaluate(expr[1], late, mask) or evaluate(expr[2], late, mask)
    raise ValueError('Unknown expression')

def ready_for(board,state,index):
    person=board.people[index]
    accepted=evaluate(person.accept,state.late,state.equipment)
    wished=evaluate(person.wish,state.late,state.equipment)
    return 1 if accepted else 2 if wished else 3

def edit(board,state,kind,value):
    if kind=='late' and value in range(3):
        return replace(state,late=value)
    if kind=='item' and value in range(3):
        return replace(state,equipment=state.equipment^(1<<value))
    if kind=='ask' and value in range(3):
        bit=1<<value
        if state.heard&bit or state.questions<=0:
            return state
        return replace(state,asked=state.asked|bit,heard=state.heard|bit,questions=state.questions-1)
    return state

STAGE = 3
BOARD = BOARDS[0]
LABELS = ("MINA","KOU","HARU")
ITEM_LABELS = ("LIGHT","CART","LOCK")

def formula(expr):
    op=expr[0]
    if op=='first':return LABELS[expr[1]]+' FIRST'
    if op=='late':return LABELS[expr[1]]+' LATE'
    if op=='same':return LABELS[expr[1]]+' SAME '+LABELS[expr[2]]
    if op=='item':return ITEM_LABELS[expr[1]]
    return '('+formula(expr[1])+(' & ' if op=='and' else ' | ')+formula(expr[2])+')'

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        heard=self.state.heard if hasattr(self,"state") else 0
        self.state=replace(initial(BOARD),heard=heard)
        self.person=0
        self.field=0
        self.gated=False
        self.message='READY'

    def commit(self,kind,value=0):
        old=self.state
        self.state=edit(BOARD,self.state,kind,value)
        self.message=kind.upper() if self.state!=old else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key in ('LEFT','RIGHT'):
            self.person=(self.person+(-1 if key=='LEFT' else 1))%3
        elif key in ('UP','DOWN'):
            self.field=(self.field+(-1 if key=='UP' else 1))%4
        elif key=='Z':
            self.commit('late',self.person) if self.field==0 else self.commit('item',self.field-1)
        elif key=='H':self.commit('ask',self.person)

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,10,'ROOM COUNCIL / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'FICTIONAL TOY / NOT REAL EMERGENCY PLANNING',6)
        p.text(12,44,'SPENT '+str(cost(s.equipment))+' / BUDGET '+str(BOARD.budget)+' / SPARE '+str(BOARD.budget-cost(s.equipment)),11 if cost(s.equipment)<=BOARD.budget else 8)
        for i,name in enumerate(LABELS):
            y=75+i*68
            p.rectb(10,y-7,576,61,10 if i==self.person else 5)
            p.text(18,y,name+(' : LATE BOAT' if i==s.late else ' : FIRST BOAT'),7)
            p.text(18,y+15,'WISH '+formula(BOARD.people[i].wish)+' = '+str(evaluate(BOARD.people[i].wish,s.late,s.equipment)),6)
            known=bool(s.heard>>i&1)
            if known:
                ready=ready_for(BOARD,s,i) if STAGE==3 else outcome(BOARD,s.late,s.equipment).ready[i] if outcome(BOARD,s.late,s.equipment) else None
                p.text(18,y+30,'ACCEPT '+formula(BOARD.people[i].accept),14)
                p.text(470,y+45,'READY '+str(ready),11)
            else:p.text(18,y+30,'ACCEPT ? / READY 1..3 / PRESS H TO LISTEN',14)
        values=[LABELS[s.late]]+[('ON' if s.equipment>>i&1 else 'OFF') for i in range(3)]
        for i,name in enumerate(('LATE',)+ITEM_LABELS):
            x=12+i*145
            p.rectb(x,285,138,27,10 if i==self.field else 5)
            p.text(x+5,295,name+' '+values[i],7)
        p.text(12,326,'QUESTIONS '+str(s.questions)+' / HEARD MASK '+str(s.heard),6)
        p.text(12,416,self.message,10)
        p.text(12,438,'LEFT/RIGHT: PERSON / UP/DOWN: FIELD / Z: CHANGE / R: RESET',7)
        p.text(12,455,'H: LISTEN / RESET KEEPS HEARD REASONS / ESC: QUIT',7)

def make_demo_keys():
    return ('H','RIGHT','H','RIGHT','H','DOWN','DOWN','Z','DOWN','Z')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,500,title='Room council lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'H':p.KEY_H,'V':p.KEY_V}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                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 keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

並行準備と、二便の船を計算する

一番遅いものに合わせる時刻表と、結果の再生。

ここで、元のゲームの状況 A の計画の計算と時計を完成させます。3つの家族の準備は、同時に始まります。初便は、2つの家族の準備が両方終わったときに出発するので、準備の刻を足すのではなく、大きいほう(max)を取ります。出発の1刻後に高台に着き、出発の2刻後に船が町に戻ります。

後便が出発するのは、「船が戻ること」と「後便の家族の準備」の、遅いほうです。そこから1刻で全員が高台に着きます。だれかの準備が3刻かかっても必ず終わり、見捨てる選択や、取り返しのつかない締め切りはありません。元の3つの会議の全72通りの計画は、すべて予算内で、全員が4〜6刻で着きます。速さだけが、ただ1つの目的ではありません。

Enter で計画を提出すると、全員の事情がわかり、結果 Outcome を State に固定します。Space は、提出した時刻表を1刻ずつ再生するだけです。提出したあとに設備や船を変えて、途中の Outcome を混ぜることはしません。V で計画の編集に戻ると、時計は0・submitted=None になり、聞いた事情はそのまま残ります。教材では確認の画面を省き、Enter 1回で提出する簡単な操作にしています。

全員の事情を知る前の編集画面では、到着の時刻は 4..6 という範囲だけを表示します。全員に聞いたあと、または提出したあとに、正確な時刻を表示します。台車と戸締りなら、全員の準備1刻・全員到着4・残りの予算1。戸締りだけなら、準備 (1,2,1)・到着5・残りの予算3。どちらも基本の目標は満たしていて、この数字を1つの総合点にまとめることはしません。

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

04_departures.py / 注目する部分
    first = max((ready[i] for i in range(3) if i != late))
    returned = first + 2
    second = max(returned, ready[late])

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_departures.py
macOS / Linux
.venv/bin/python 04_departures.py
画面で見ること下 → 下 → Z → 下 → Z → Enter → Space を4回押します。全員 READY 1、FIRST 1→2、RETURN 3、SECOND 3→4、CLOCK 4、ALL ARRIVED、SPARE 1 です。
  • 同時に進む準備は、一番遅いもの(max)に合わせる
  • 船が戻ることと、準備の両方を待つ
  • 提出した計画を固定して、再生する
つまずきやすいところ準備を 1+1+1=3 と足してから船を出さないでください。準備は同時に進みます。後便も、船が戻る前には出せないので、max で両方の条件を待ちます。
少し変えてみよう

R → 下 → 下 → 下 → Z で戸締りだけにして、Enter を押してみましょう。Space を5回押すと、到着5、準備 (1,2,1)、SPARE 3 です。台車もある計画と比べて、到着の時刻と、残しておく備え(予算)が、別のものさしになっていることを確かめましょう。

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

"""ひと部屋の災害会議・教材4/5。架空の人物の有限模型。現実の防災助言ではありません。"""
from pathlib import Path

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '086.1.0'

ITEMS = ('灯り', '台車', '戸締り')

COSTS = (1, 2, 1)

@dataclass(frozen=True)
class Person:
    name: str
    intro: str
    wish: tuple
    accept: tuple
    story: str

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    budget: int
    people: tuple

def tree(v):
    return tuple((tree(x) for x in v)) if isinstance(v, list) else v

DATA = [{'id': 'A', 'title': '暗くなる前', 'budget': 4, 'people': [{'name': 'ミナ', 'intro': '早めに船へ乗りたい世帯', 'wish': ['first', 0], 'accept': ['or', ['first', 0], ['item', 0]], 'story': '暗い待合が心配。灯りがあれば後便でも大丈夫。'}, {'name': 'コウ', 'intro': '川沿いで店を営む世帯', 'wish': ['item', 2], 'accept': ['and', ['item', 2], ['item', 1]], 'story': '戸締りだけでなく、台車で店の品も運びたい。'}, {'name': 'ハル', 'intro': '知り合いの隣が安心な世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 0], ['late', 2]]], 'story': 'ミナが初便なら、自分は後便で見送りを引き受ける。'}]}, {'id': 'B', 'title': '荷物の多い朝', 'budget': 4, 'people': [{'name': 'ソラ', 'intro': '荷物をゆっくり運びたい世帯', 'wish': ['item', 1], 'accept': ['or', ['item', 1], ['and', ['late', 0], ['item', 0]]], 'story': '台車がなくても、灯りの下で後便まで準備できる。'}, {'name': 'ナギ', 'intro': '先発で店を離れる世帯', 'wish': ['first', 1], 'accept': ['and', ['first', 1], ['item', 2]], 'story': '初便がよい。店の戸締りも済ませてから出たい。'}, {'name': 'トワ', 'intro': 'ソラの荷物を気にかける世帯', 'wish': ['same', 0, 2], 'accept': ['or', ['same', 0, 2], ['item', 1]], 'story': 'ソラと別の便でも、台車があるなら安心できる。'}]}, {'id': 'C', 'title': '最後の見送り', 'budget': 4, 'people': [{'name': 'ユズ', 'intro': '最後を見送ってから行く世帯', 'wish': ['late', 0], 'accept': ['and', ['late', 0], ['item', 0]], 'story': '後便で見送りたい。待合の灯りも必要。'}, {'name': 'レン', 'intro': '店の戸を気にする世帯', 'wish': ['item', 2], 'accept': ['or', ['item', 2], ['and', ['first', 1], ['item', 1]]], 'story': '戸締りか、初便で台車と品を運ぶ方法ならよい。'}, {'name': 'アオ', 'intro': 'レンとの同行を考える世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 2], ['late', 0]]], 'story': '別便でも、自分が初便でユズが見送るならよい。'}]}]

BOARDS = tuple((Board(d['id'], d['title'], d['budget'], tuple((Person(p['name'], p['intro'], tree(p['wish']), tree(p['accept']), p['story']) for p in d['people']))) for d in DATA))

@dataclass(frozen=True)
class Outcome:
    late: int
    equipment: int
    wished: tuple
    satisfied: tuple
    ready: tuple
    first_depart: int
    first_arrival: int
    returned: int
    second_depart: int
    final_arrival: int
    spare: int

@dataclass(frozen=True)
class State:
    asked: int = 0
    heard: int = 0
    questions: int = 3
    late: int = 2
    equipment: int = 0
    submitted: object = None
    clock: int = 0
    edits: int = 0
    events: tuple = ()

@dataclass(frozen=True)
class Action:
    kind: str
    value: int = 0

def initial(b):
    return State()

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

def cost(mask):
    return sum((c for i, c in enumerate(COSTS) if mask >> i & 1))

def evaluate(expr, late, mask):
    op = expr[0]
    if op == 'first':
        return late != expr[1]
    if op == 'late':
        return late == expr[1]
    if op == 'same':
        return (late == expr[1]) == (late == expr[2])
    if op == 'item':
        return bool(mask >> expr[1] & 1)
    if op == 'and':
        return evaluate(expr[1], late, mask) and evaluate(expr[2], late, mask)
    if op == 'or':
        return evaluate(expr[1], late, mask) or evaluate(expr[2], late, mask)
    raise ValueError('Unknown expression')

def expression(b, expr):
    op = expr[0]
    if op == 'first':
        return b.people[expr[1]].name + '初便'
    if op == 'late':
        return b.people[expr[1]].name + '後便'
    if op == 'same':
        return b.people[expr[1]].name + 'と' + b.people[expr[2]].name + '同便'
    if op == 'item':
        return ITEMS[expr[1]]
    return '(' + expression(b, expr[1]) + (' かつ ' if op == 'and' else ' または ') + expression(b, expr[2]) + ')'

def outcome(b, late, mask):
    if late not in range(3) or mask not in range(8) or cost(mask) > b.budget:
        return None
    wished = tuple((evaluate(p.wish, late, mask) for p in b.people))
    satisfied = tuple((evaluate(p.accept, late, mask) for p in b.people))
    ready = tuple((1 if ok else 2 if w else 3 for ok, w in zip(satisfied, wished)))
    first = max((ready[i] for i in range(3) if i != late))
    returned = first + 2
    second = max(returned, ready[late])
    return Outcome(late, mask, wished, satisfied, ready, first, first + 1, returned, second, second + 1, b.budget - cost(mask))

def public(b, s, compare=False):
    known = s.heard | (7 if compare or s.submitted else 0)
    people = []
    o = outcome(b, s.late, s.equipment)
    for i, p in enumerate(b.people):
        seen = bool(known >> i & 1)
        people.append({'name': p.name, 'intro': p.intro, 'wish': expression(b, p.wish), 'wished': evaluate(p.wish, s.late, s.equipment), 'known': seen, 'story': p.story if seen else 'まだ事情を聞いていない', 'accept': expression(b, p.accept) if seen else '事情の受入式は未確認', 'ready': o.ready[i] if seen and o else None, 'satisfied': o.satisfied[i] if seen and o else None})
    return {'people': people, 'spare': b.budget - cost(s.equipment), 'known': known, 'all_known': known == 7, 'outcome': asdict(o) if known == 7 and o else None, 'arrival_range': (4, 6) if o else None}

def step(b, s, a):
    if a.kind == 'reveal':
        return replace(s, heard=7)
    if a.kind == 'revise' and s.submitted:
        return replace(s, submitted=None, clock=0, heard=7, events=('計画を戻しました。見た事情と前の計画は残ります。',))
    if a.kind == 'tick' and s.submitted:
        o = s.submitted
        if s.clock >= o.final_arrival:
            return s
        t = s.clock + 1
        notes = []
        for i, ready in enumerate(o.ready):
            if t == ready:
                notes.append(b.people[i].name + 'の準備完了')
        if t == o.first_depart:
            notes.append('初便の二世帯が出発')
        if t == o.first_arrival:
            notes.append('初便の二世帯が高台へ到着')
        if t == o.returned:
            notes.append('船が町へ戻った')
        if t == o.second_depart:
            notes.append('後便の' + b.people[o.late].name + 'が出発')
        if t == o.final_arrival:
            notes.append('全世帯が高台へ到着')
        return replace(s, clock=t, events=tuple(notes) or ('準備と渡航が進む',))
    if s.submitted:
        return s
    if a.kind == 'ask' and a.value in range(3):
        bit = 1 << a.value
        if s.heard & bit:
            return s
        if s.questions <= 0:
            return s
        return replace(s, asked=s.asked | bit, heard=s.heard | bit, questions=s.questions - 1, edits=s.edits + 1, events=(b.people[a.value].story,))
    if a.kind == 'late' and a.value in range(3):
        return replace(s, late=a.value, edits=s.edits + (s.late != a.value), events=('後便は' + b.people[a.value].name + '。他の二世帯は初便。',))
    if a.kind == 'item' and a.value in range(3):
        return replace(s, equipment=s.equipment ^ 1 << a.value, edits=s.edits + 1, events=(ITEMS[a.value] + 'を切り替えた',))
    if a.kind == 'submit':
        o = outcome(b, s.late, s.equipment)
        if o is None:
            return s
        return replace(s, submitted=o, heard=7, clock=0, edits=s.edits + 1, events=('計画決定。全員の事情を開きます。',))
    return s

STAGE = 4
BOARD = BOARDS[0]
LABELS = ("MINA","KOU","HARU")
ITEM_LABELS = ("LIGHT","CART","LOCK")

def formula(expr):
    op=expr[0]
    if op=='first':return LABELS[expr[1]]+' FIRST'
    if op=='late':return LABELS[expr[1]]+' LATE'
    if op=='same':return LABELS[expr[1]]+' SAME '+LABELS[expr[2]]
    if op=='item':return ITEM_LABELS[expr[1]]
    return '('+formula(expr[1])+(' & ' if op=='and' else ' | ')+formula(expr[2])+')'

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        heard=self.state.heard if hasattr(self,"state") else 0
        self.state=replace(initial(BOARD),heard=heard)
        self.person=0
        self.field=0
        self.gated=False
        self.message='READY'

    def commit(self,kind,value=0):
        old=self.state
        self.state=step(BOARD,self.state,Action(kind,value))
        self.message=kind.upper() if self.state!=old else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='V':self.commit('revise');return
        if key=='RETURN':self.commit('submit');return
        if key=='SPACE':self.commit('tick');return
        if self.state.submitted:return
        if key in ('LEFT','RIGHT'):
            self.person=(self.person+(-1 if key=='LEFT' else 1))%3
        elif key in ('UP','DOWN'):
            self.field=(self.field+(-1 if key=='UP' else 1))%4
        elif key=='Z':
            self.commit('late',self.person) if self.field==0 else self.commit('item',self.field-1)
        elif key=='H':self.commit('ask',self.person)

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,10,'ROOM COUNCIL / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'FICTIONAL TOY / NOT REAL EMERGENCY PLANNING',6)
        p.text(12,44,'SPENT '+str(cost(s.equipment))+' / BUDGET '+str(BOARD.budget)+' / SPARE '+str(BOARD.budget-cost(s.equipment)),11 if cost(s.equipment)<=BOARD.budget else 8)
        for i,name in enumerate(LABELS):
            y=75+i*68
            p.rectb(10,y-7,576,61,10 if i==self.person else 5)
            p.text(18,y,name+(' : LATE BOAT' if i==s.late else ' : FIRST BOAT'),7)
            p.text(18,y+15,'WISH '+formula(BOARD.people[i].wish)+' = '+str(evaluate(BOARD.people[i].wish,s.late,s.equipment)),6)
            known=bool(s.heard>>i&1)
            if known:
                ready=ready_for(BOARD,s,i) if STAGE==3 else outcome(BOARD,s.late,s.equipment).ready[i] if outcome(BOARD,s.late,s.equipment) else None
                p.text(18,y+30,'ACCEPT '+formula(BOARD.people[i].accept),14)
                p.text(470,y+45,'READY '+str(ready),11)
            else:p.text(18,y+30,'ACCEPT ? / READY 1..3 / PRESS H TO LISTEN',14)
        values=[LABELS[s.late]]+[('ON' if s.equipment>>i&1 else 'OFF') for i in range(3)]
        for i,name in enumerate(('LATE',)+ITEM_LABELS):
            x=12+i*145
            p.rectb(x,285,138,27,10 if i==self.field else 5)
            p.text(x+5,295,name+' '+values[i],7)
        p.text(12,326,'QUESTIONS '+str(s.questions)+' / HEARD MASK '+str(s.heard),6)
        view=public(BOARD,s)
        o=s.submitted
        if o:
            p.text(12,344,'CLOCK '+str(s.clock)+' / ALL ARRIVE '+str(o.final_arrival)+' / READY '+str(o.ready),11)
            p.text(12,361,'FIRST '+str(o.first_depart)+' -> '+str(o.first_arrival)+' / RETURN '+str(o.returned)+' / SECOND '+str(o.second_depart)+' -> '+str(o.final_arrival),7)
            p.text(12,378,'ALL ARRIVED' if s.clock>=o.final_arrival else 'SPACE: ADVANCE ONE TICK',10)
        elif view['outcome']:
            p.text(12,344,'KNOWN PLAN / ALL ARRIVE AT '+str(view['outcome']['final_arrival']),11)
        elif view['arrival_range']:
            p.text(12,344,'ARRIVAL RANGE 4..6 / UNHEARD REASONS HIDDEN',14)
        else:p.text(12,344,'OVER BUDGET / SUBMISSION REFUSED',8)
        p.text(12,416,self.message,10)
        p.text(12,438,'LEFT/RIGHT: PERSON / UP/DOWN: FIELD / Z: CHANGE / R: RESET',7)
        p.text(12,455,'H: LISTEN / ENTER: SUBMIT / SPACE: TICK / V: REVISE / ESC: QUIT',7)

def make_demo_keys():
    return ('DOWN','DOWN','Z','DOWN','Z','RETURN')+('SPACE',)*4

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,500,title='Room council lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'H':p.KEY_H,'V':p.KEY_V}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                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 keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

計画を戻しても、見た事情は消さない

巻き戻す状態と、覚えておく記憶。

最後に Timeline を加えます。状態が変わった判断の前の状態を history に保存し、X で1件戻します。ただし、事情を「もう知っている」ビットは、戻る前の状態と戻した State の OR(どちらかで立っていれば立てる)を取って残します。過去の計画に戻ることと、プレイヤーがもう読んだことを「知らなかった」ことにするのは、別のことです。

H でミナに聞いてから X を押すと、asked 0・questions 3 に戻りますが、heard 1 は残ります。もう一度 H を押しても、もう知っている事情を読み直すだけなので、質問の回数は使いません。State の全項目を古い値に戻すだけの Undo では、この作りは再現できません。位置だけを戻すゲームとも、すべてを巻き戻すゲームとも違う選び方です。

提出した Outcome は、別の archive(提出した案の一覧)に保存します。提出を取り消しても、過去に提出した案は archive から消しません。同じ案をもう一度提出すれば、2件になります。これはゲームを動かしている間だけの記録で、ディスクへの保存ではありません。journal には Undo したことも書くので、最初から同じ意味の入力を再生すれば、「知っている情報が残ること」と提出の履歴を再現できます。

教材の X は、提出したあとも State を1つずつ戻すので、時計を1刻戻す Undo にも使えます。まとめて編集に戻るのは V です。元のゲームのふつうの画面では、提出後の X が計画の編集に戻る操作なので、この違いを分けて覚えましょう。R は「知っている情報」だけを残し、履歴・提出した案の一覧・journal を空にして、新しく挑戦します。

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

05_known_undo.py / 注目する部分
        self.state = replace(old, heard=old.heard | heard)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_known_undo.py
macOS / Linux
.venv/bin/python 05_known_undo.py
画面で見ることH → X → H と押します。HEARD MASK 1、QUESTIONS 3、HISTORY 0 のままです。台車と戸締りを確定 → Enter → Space → V と押すと、時計0の編集画面で、全員の事情は知ったまま、SUBMITTED ARCHIVE 1 です。
  • 計画と、知っている情報を、別々に扱う
  • 提出の記録は、Undo しても残す
  • Undo も含めた意味のある入力を再生する
つまずきやすいところUndo のたびに archive から過去の提出を消したり、heard を古い値だけに戻したりしないでください。残す情報と戻す情報を、項目ごとに決めます。
少し変えてみよう

Enter で提出してから X を押してみましょう。submitted はなくなりますが、ARCHIVE 1 と HEARD MASK 7 は残ります。もう一度 Enter で ARCHIVE 2 です。Space → X なら、同じ提出した計画の CLOCK 0 に戻ります。最後に R で ARCHIVE 0 になり、事情は知ったままであることを確かめます。

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

"""ひと部屋の災害会議・教材5/5。架空の人物の有限模型。現実の防災助言ではありません。"""
from pathlib import Path

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '086.1.0'

ITEMS = ('灯り', '台車', '戸締り')

COSTS = (1, 2, 1)

@dataclass(frozen=True)
class Person:
    name: str
    intro: str
    wish: tuple
    accept: tuple
    story: str

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    budget: int
    people: tuple

def tree(v):
    return tuple((tree(x) for x in v)) if isinstance(v, list) else v

DATA = [{'id': 'A', 'title': '暗くなる前', 'budget': 4, 'people': [{'name': 'ミナ', 'intro': '早めに船へ乗りたい世帯', 'wish': ['first', 0], 'accept': ['or', ['first', 0], ['item', 0]], 'story': '暗い待合が心配。灯りがあれば後便でも大丈夫。'}, {'name': 'コウ', 'intro': '川沿いで店を営む世帯', 'wish': ['item', 2], 'accept': ['and', ['item', 2], ['item', 1]], 'story': '戸締りだけでなく、台車で店の品も運びたい。'}, {'name': 'ハル', 'intro': '知り合いの隣が安心な世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 0], ['late', 2]]], 'story': 'ミナが初便なら、自分は後便で見送りを引き受ける。'}]}, {'id': 'B', 'title': '荷物の多い朝', 'budget': 4, 'people': [{'name': 'ソラ', 'intro': '荷物をゆっくり運びたい世帯', 'wish': ['item', 1], 'accept': ['or', ['item', 1], ['and', ['late', 0], ['item', 0]]], 'story': '台車がなくても、灯りの下で後便まで準備できる。'}, {'name': 'ナギ', 'intro': '先発で店を離れる世帯', 'wish': ['first', 1], 'accept': ['and', ['first', 1], ['item', 2]], 'story': '初便がよい。店の戸締りも済ませてから出たい。'}, {'name': 'トワ', 'intro': 'ソラの荷物を気にかける世帯', 'wish': ['same', 0, 2], 'accept': ['or', ['same', 0, 2], ['item', 1]], 'story': 'ソラと別の便でも、台車があるなら安心できる。'}]}, {'id': 'C', 'title': '最後の見送り', 'budget': 4, 'people': [{'name': 'ユズ', 'intro': '最後を見送ってから行く世帯', 'wish': ['late', 0], 'accept': ['and', ['late', 0], ['item', 0]], 'story': '後便で見送りたい。待合の灯りも必要。'}, {'name': 'レン', 'intro': '店の戸を気にする世帯', 'wish': ['item', 2], 'accept': ['or', ['item', 2], ['and', ['first', 1], ['item', 1]]], 'story': '戸締りか、初便で台車と品を運ぶ方法ならよい。'}, {'name': 'アオ', 'intro': 'レンとの同行を考える世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 2], ['late', 0]]], 'story': '別便でも、自分が初便でユズが見送るならよい。'}]}]

BOARDS = tuple((Board(d['id'], d['title'], d['budget'], tuple((Person(p['name'], p['intro'], tree(p['wish']), tree(p['accept']), p['story']) for p in d['people']))) for d in DATA))

@dataclass(frozen=True)
class Outcome:
    late: int
    equipment: int
    wished: tuple
    satisfied: tuple
    ready: tuple
    first_depart: int
    first_arrival: int
    returned: int
    second_depart: int
    final_arrival: int
    spare: int

@dataclass(frozen=True)
class State:
    asked: int = 0
    heard: int = 0
    questions: int = 3
    late: int = 2
    equipment: int = 0
    submitted: object = None
    clock: int = 0
    edits: int = 0
    events: tuple = ()

@dataclass(frozen=True)
class Action:
    kind: str
    value: int = 0

def initial(b):
    return State()

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

def cost(mask):
    return sum((c for i, c in enumerate(COSTS) if mask >> i & 1))

def evaluate(expr, late, mask):
    op = expr[0]
    if op == 'first':
        return late != expr[1]
    if op == 'late':
        return late == expr[1]
    if op == 'same':
        return (late == expr[1]) == (late == expr[2])
    if op == 'item':
        return bool(mask >> expr[1] & 1)
    if op == 'and':
        return evaluate(expr[1], late, mask) and evaluate(expr[2], late, mask)
    if op == 'or':
        return evaluate(expr[1], late, mask) or evaluate(expr[2], late, mask)
    raise ValueError('Unknown expression')

def expression(b, expr):
    op = expr[0]
    if op == 'first':
        return b.people[expr[1]].name + '初便'
    if op == 'late':
        return b.people[expr[1]].name + '後便'
    if op == 'same':
        return b.people[expr[1]].name + 'と' + b.people[expr[2]].name + '同便'
    if op == 'item':
        return ITEMS[expr[1]]
    return '(' + expression(b, expr[1]) + (' かつ ' if op == 'and' else ' または ') + expression(b, expr[2]) + ')'

def outcome(b, late, mask):
    if late not in range(3) or mask not in range(8) or cost(mask) > b.budget:
        return None
    wished = tuple((evaluate(p.wish, late, mask) for p in b.people))
    satisfied = tuple((evaluate(p.accept, late, mask) for p in b.people))
    ready = tuple((1 if ok else 2 if w else 3 for ok, w in zip(satisfied, wished)))
    first = max((ready[i] for i in range(3) if i != late))
    returned = first + 2
    second = max(returned, ready[late])
    return Outcome(late, mask, wished, satisfied, ready, first, first + 1, returned, second, second + 1, b.budget - cost(mask))

def public(b, s, compare=False):
    known = s.heard | (7 if compare or s.submitted else 0)
    people = []
    o = outcome(b, s.late, s.equipment)
    for i, p in enumerate(b.people):
        seen = bool(known >> i & 1)
        people.append({'name': p.name, 'intro': p.intro, 'wish': expression(b, p.wish), 'wished': evaluate(p.wish, s.late, s.equipment), 'known': seen, 'story': p.story if seen else 'まだ事情を聞いていない', 'accept': expression(b, p.accept) if seen else '事情の受入式は未確認', 'ready': o.ready[i] if seen and o else None, 'satisfied': o.satisfied[i] if seen and o else None})
    return {'people': people, 'spare': b.budget - cost(s.equipment), 'known': known, 'all_known': known == 7, 'outcome': asdict(o) if known == 7 and o else None, 'arrival_range': (4, 6) if o else None}

def step(b, s, a):
    if a.kind == 'reveal':
        return replace(s, heard=7)
    if a.kind == 'revise' and s.submitted:
        return replace(s, submitted=None, clock=0, heard=7, events=('計画を戻しました。見た事情と前の計画は残ります。',))
    if a.kind == 'tick' and s.submitted:
        o = s.submitted
        if s.clock >= o.final_arrival:
            return s
        t = s.clock + 1
        notes = []
        for i, ready in enumerate(o.ready):
            if t == ready:
                notes.append(b.people[i].name + 'の準備完了')
        if t == o.first_depart:
            notes.append('初便の二世帯が出発')
        if t == o.first_arrival:
            notes.append('初便の二世帯が高台へ到着')
        if t == o.returned:
            notes.append('船が町へ戻った')
        if t == o.second_depart:
            notes.append('後便の' + b.people[o.late].name + 'が出発')
        if t == o.final_arrival:
            notes.append('全世帯が高台へ到着')
        return replace(s, clock=t, events=tuple(notes) or ('準備と渡航が進む',))
    if s.submitted:
        return s
    if a.kind == 'ask' and a.value in range(3):
        bit = 1 << a.value
        if s.heard & bit:
            return s
        if s.questions <= 0:
            return s
        return replace(s, asked=s.asked | bit, heard=s.heard | bit, questions=s.questions - 1, edits=s.edits + 1, events=(b.people[a.value].story,))
    if a.kind == 'late' and a.value in range(3):
        return replace(s, late=a.value, edits=s.edits + (s.late != a.value), events=('後便は' + b.people[a.value].name + '。他の二世帯は初便。',))
    if a.kind == 'item' and a.value in range(3):
        return replace(s, equipment=s.equipment ^ 1 << a.value, edits=s.edits + 1, events=(ITEMS[a.value] + 'を切り替えた',))
    if a.kind == 'submit':
        o = outcome(b, s.late, s.equipment)
        if o is None:
            return s
        return replace(s, submitted=o, heard=7, clock=0, edits=s.edits + 1, events=('計画決定。全員の事情を開きます。',))
    return s

class Timeline:

    def __init__(self, b, heard=0):
        self.board = b
        self.state = replace(initial(b), heard=heard)
        self.history = []
        self.inputs = []
        self.archive = []
        self.journal = []
        self.initial_heard = heard

    def act(self, a):
        n = step(self.board, self.state, a)
        if n == self.state:
            return False
        self.history.append(self.state)
        self.inputs.append(a)
        self.journal.append(asdict(a))
        self.state = n
        if a.kind == 'submit':
            self.archive.append(n.submitted)
        return True

    def undo(self):
        if not self.history:
            return False
        heard = self.state.heard
        old = self.history.pop()
        self.state = replace(old, heard=old.heard | heard)
        self.inputs.pop()
        self.journal.append({'kind': 'undo', 'value': 0})
        return True

STAGE = 5
BOARD = BOARDS[0]
LABELS = ("MINA","KOU","HARU")
ITEM_LABELS = ("LIGHT","CART","LOCK")

def formula(expr):
    op=expr[0]
    if op=='first':return LABELS[expr[1]]+' FIRST'
    if op=='late':return LABELS[expr[1]]+' LATE'
    if op=='same':return LABELS[expr[1]]+' SAME '+LABELS[expr[2]]
    if op=='item':return ITEM_LABELS[expr[1]]
    return '('+formula(expr[1])+(' & ' if op=='and' else ' | ')+formula(expr[2])+')'

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        heard=self.state.heard if hasattr(self,"state") else 0
        self.timeline=Timeline(BOARD,heard)
        self.person=0
        self.field=0
        self.gated=False
        self.message='READY'

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

    def commit(self,kind,value=0):
        old=self.state
        if kind=='undo':self.timeline.undo()
        else:self.timeline.act(Action(kind,value))
        self.message=kind.upper() if self.state!=old else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='X':self.commit('undo');return
        if key=='V':self.commit('revise');return
        if key=='RETURN':self.commit('submit');return
        if key=='SPACE':self.commit('tick');return
        if self.state.submitted:return
        if key in ('LEFT','RIGHT'):
            self.person=(self.person+(-1 if key=='LEFT' else 1))%3
        elif key in ('UP','DOWN'):
            self.field=(self.field+(-1 if key=='UP' else 1))%4
        elif key=='Z':
            self.commit('late',self.person) if self.field==0 else self.commit('item',self.field-1)
        elif key=='H':self.commit('ask',self.person)

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,10,'ROOM COUNCIL / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'FICTIONAL TOY / NOT REAL EMERGENCY PLANNING',6)
        p.text(12,44,'SPENT '+str(cost(s.equipment))+' / BUDGET '+str(BOARD.budget)+' / SPARE '+str(BOARD.budget-cost(s.equipment)),11 if cost(s.equipment)<=BOARD.budget else 8)
        for i,name in enumerate(LABELS):
            y=75+i*68
            p.rectb(10,y-7,576,61,10 if i==self.person else 5)
            p.text(18,y,name+(' : LATE BOAT' if i==s.late else ' : FIRST BOAT'),7)
            p.text(18,y+15,'WISH '+formula(BOARD.people[i].wish)+' = '+str(evaluate(BOARD.people[i].wish,s.late,s.equipment)),6)
            known=bool(s.heard>>i&1)
            if known:
                ready=ready_for(BOARD,s,i) if STAGE==3 else outcome(BOARD,s.late,s.equipment).ready[i] if outcome(BOARD,s.late,s.equipment) else None
                p.text(18,y+30,'ACCEPT '+formula(BOARD.people[i].accept),14)
                p.text(470,y+45,'READY '+str(ready),11)
            else:p.text(18,y+30,'ACCEPT ? / READY 1..3 / PRESS H TO LISTEN',14)
        values=[LABELS[s.late]]+[('ON' if s.equipment>>i&1 else 'OFF') for i in range(3)]
        for i,name in enumerate(('LATE',)+ITEM_LABELS):
            x=12+i*145
            p.rectb(x,285,138,27,10 if i==self.field else 5)
            p.text(x+5,295,name+' '+values[i],7)
        p.text(12,326,'QUESTIONS '+str(s.questions)+' / HEARD MASK '+str(s.heard),6)
        view=public(BOARD,s)
        o=s.submitted
        if o:
            p.text(12,344,'CLOCK '+str(s.clock)+' / ALL ARRIVE '+str(o.final_arrival)+' / READY '+str(o.ready),11)
            p.text(12,361,'FIRST '+str(o.first_depart)+' -> '+str(o.first_arrival)+' / RETURN '+str(o.returned)+' / SECOND '+str(o.second_depart)+' -> '+str(o.final_arrival),7)
            p.text(12,378,'ALL ARRIVED' if s.clock>=o.final_arrival else 'SPACE: ADVANCE ONE TICK',10)
        elif view['outcome']:
            p.text(12,344,'KNOWN PLAN / ALL ARRIVE AT '+str(view['outcome']['final_arrival']),11)
        elif view['arrival_range']:
            p.text(12,344,'ARRIVAL RANGE 4..6 / UNHEARD REASONS HIDDEN',14)
        else:p.text(12,344,'OVER BUDGET / SUBMISSION REFUSED',8)
        p.text(12,395,'HISTORY '+str(len(self.timeline.history))+' / SUBMITTED ARCHIVE '+str(len(self.timeline.archive)),6)
        p.text(12,416,self.message,10)
        p.text(12,438,'LEFT/RIGHT: PERSON / UP/DOWN: FIELD / Z: CHANGE / R: RESET',7)
        p.text(12,455,'H: LISTEN / ENTER: SUBMIT / SPACE: TICK / V: REVISE / ESC: QUIT',7)
        p.text(12,472,'X: ONE STATE UNDO; HEARD REASONS AND SUBMITTED ARCHIVE STAY',7)

def make_demo_keys():
    return ('H','X','DOWN','DOWN','Z','DOWN','Z','RETURN','SPACE','V')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,500,title='Room council lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'H':p.KEY_H,'V':p.KEY_V}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                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 keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:架空の3つの家族を、初便2家族・後便1家族に分けます。設備の費用と、人物が口にした希望・引き受けられる条件から準備の刻を決め、同時に進む準備と船の往復から到着の時刻を計算します。元のゲームの全72通りの計画で、全員が4〜6刻で着き、失敗や見捨てる選択はありません。現実の避難のアドバイスではありません。

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

比べる点5ステップの教材元の完成版
段階01 計画の編集、02 口にした希望、03 事情と準備。04 で元のゲームの到着の計算、05 で記憶を残す Undo。最初から、会話・計画・提出・再生・知った情報を残す仕組みがあります。
操作英数字(ASCII)の表示で、状況 A だけ。Enter 1回で提出、V で編集へ、05 の X は提出後も State を1つずつ戻す。R はすぐにやり直し。日本語の表示、会話と編集を Tab で切り替え、提出の確認、提出後の X で編集へ、状況 A/B/C と情報の表示のしかたの比較。
データと保存元の councils.json のデータを全部、それぞれの独立したファイルに入れています。3つの会議すべてのルールをテストしています。archive/journal はゲームを動かしている間だけ。councils.json は別のファイル。記録の保存・再現用の資料・日本語の文字。

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

  1. councils.json:口にした希望と、引き受けられる条件の式のデータ
  2. model.py の evaluate:式を再帰で読む仕組み
  3. model.py の outcome:同時に進む準備と、船の時刻の計算
  4. model.py の public:知っている情報だけを表示する仕組み
  5. model.py の Timeline.undo:heard の OR と archive
  6. controller.py:提出後の X・確認の操作と、知った情報を残す仕組み
自分のゲームに変えるなら

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

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