# 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<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