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