# 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. """098 一冊の動く絵本・日本語教材 4/5。""" from dataclasses import dataclass, replace, asdict import json WIDTH, HEIGHT = 800, 560 TITLES = ('渡し舟のページ', '橋のページ', '風のページ') NAMES = (('舟', '石'), ('橋', '旗'), ('葉舟', '看板')) LAND = ((0, 1, 5, 6), (0, 1, 5, 6), (0, 1, 3, 4, 6)) TAGS = (('重い', '軽い'), ('短い', '長い'), ('軽い', '重い')) EN_NAMES = (('BOAT', 'STONE'), ('BRIDGE', 'FLAG'), ('LEAF BOAT', 'SIGN')) EN_TAGS = {'重い':'HEAVY', '軽い':'LIGHT', '短い':'SHORT', '長い':'LONG'} EN_SCENES = {'浮く舟、右岸の旅人':'FLOAT BOAT / TRAVELER ON RIGHT', '浮く石、右岸の旅人':'FLOAT STONE / TRAVELER ON RIGHT', '長い橋と短い旗':'LONG BRIDGE / SHORT FLAG', '短い橋と長い旗':'SHORT BRIDGE / LONG FLAG', '軽い葉舟の空':'LIGHT LEAF BOAT SKY', '軽い看板の空':'LIGHT SIGN SKY', '葉舟で風の丘':'LEAF BOAT WIND HILL', '看板で風の丘':'SIGN WIND HILL'} STAGE = 4 PAGE_COUNT = 3 TITLE = 'WIND / THREE CHAPTERS' @dataclass(frozen=True) class Page: hero: int objects: tuple tags: tuple riding: int = -1 last_land: int = 0 raised: bool = False complete: bool = False ticks: int = 0 cause: str = '' @dataclass(frozen=True) class State: page: int pages: tuple memories: tuple variant: str = 'causes' events: tuple = () def fresh(i): return Page((1, 0, 0)[i], ((2, 4), (1, 6), (1, 4))[i], TAGS[i], last_land=(1, 0, 0)[i]) def new(variant='causes'): return State(0, tuple((fresh(i) for i in range(PAGE_COUNT))), tuple('' for _ in range(PAGE_COUNT)), variant) def scene(i, p): if i == 0: return '浮く' + NAMES[i][p.tags.index('軽い')] + '、右岸の旅人' if i == 1: return '長い橋と短い旗' if p.tags[0] == '長い' else '短い橋と長い旗' return ('葉舟で風の丘' if p.riding == 0 else '看板で風の丘') if p.riding != -1 else '軽い葉舟の空' if p.tags[0] == '軽い' else '軽い看板の空' def finish(s, p, cause): i = s.page complete = p.complete or (p.hero >= 5 if i == 0 else p.hero == 6 and p.raised if i == 1 else p.hero == 6 and p.riding == -1) p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause) pages = list(s.pages) pages[i] = p mem = list(s.memories) if complete: mem[i] = scene(i, p) return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,)) def swap(s): i = s.page p = s.pages[i] tags = tuple(reversed(p.tags)) p = replace(p, tags=tags) cause = ' / '.join((NAMES[i][j] + 'が' + tags[j] for j in (0, 1))) if i == 0: cause += ' → 浮く物と沈む物が交代' if p.riding >= 0 and tags[p.riding] == '重い': p = replace(p, hero=p.last_land, riding=-1) cause += '。旅人は泡で陸へ' if i == 1: cause += ' → 長さが同時に変わる' if p.hero in (3, 4) and tags[0] == '短い': p = replace(p, hero=p.last_land) cause += '。旅人はしおりで陸へ' if i == 2: cause += ' → 風に動く物が交代' return finish(s, p, cause) def walk(s, d): i = s.page p = s.pages[i] target = p.hero + d if d not in (-1, 1) or not 0 <= target <= 6: return s objects = list(p.objects) if i == 0: if p.riding >= 0: if target in LAND[i]: return finish(s, replace(p, hero=target, riding=-1, last_land=target), '舟から陸へ降りた' if p.riding == 0 else '石から陸へ降りた') if target in (2, 3, 4) and (not any((j != p.riding and objects[j] == target and (p.tags[j] == '軽い') for j in (0, 1)))): objects[p.riding] = target return finish(s, replace(p, hero=target, objects=tuple(objects)), '軽い' + NAMES[i][p.riding] + 'と水上を進んだ') return s if target in LAND[i]: return finish(s, replace(p, hero=target, last_land=target), '陸を歩いた') riders = [j for j in (0, 1) if objects[j] == target and p.tags[j] == '軽い'] if riders: return finish(s, replace(p, hero=target, riding=riders[0]), '軽い' + NAMES[i][riders[0]] + 'へ乗った') return s if i == 1: if target not in LAND[i] and (not (target == 2 or p.tags[0] == '長い')): return s return finish(s, replace(p, hero=target, last_land=target if target in LAND[i] else p.last_land), '橋を歩いた' if target not in LAND[i] else '陸へ歩いた') riders = sorted((j for j in (0, 1) if objects[j] == target), key=lambda j: p.tags[j] != '軽い') if riders: return finish(s, replace(p, hero=target, riding=riders[0]), NAMES[i][riders[0]] + 'へ乗った') if target in LAND[i]: return finish(s, replace(p, hero=target, riding=-1, last_land=target), '陸へ歩いた') return s def interact(s): i = s.page p = s.pages[i] if i == 0 and p.riding == -1 and (p.hero in LAND[0]): candidates = [j for j in (0, 1) if p.tags[j] == '軽い' and 1 < abs(p.objects[j] - p.hero) <= 2] if candidates: j = candidates[0] o = list(p.objects) o[j] += 1 if p.hero > o[j] else -1 return finish(s, replace(p, objects=tuple(o)), '軽い' + NAMES[i][j] + 'を1セル手前へ寄せた') if i == 1 and p.hero == 6 and (not p.raised): return finish(s, replace(p, raised=True), '旗が上がった。長さの違う結末も残せる') if i == 2 and p.riding >= 0 and (p.hero in LAND[2]): return finish(s, replace(p, riding=-1, last_land=p.hero), '乗り物から陸へ降りた') return s def wind(s): p = s.pages[s.page] if s.page != 2: return finish(s, p, '一段階待った。景色はそのまま') o = list(p.objects) j = p.tags.index('軽い') target = min(6, o[j] + 1) o[j] = target hero = o[j] if p.riding == j else p.hero return finish(s, replace(p, objects=tuple(o), hero=hero), '軽い' + NAMES[2][j] + 'を風が1セル運んだ' if target != p.objects[j] else '丘の端で風の乗り物が止まった') def step(s, a): if a == 'swap': return swap(s) if a == 'left': return walk(s, -1) if a == 'right': return walk(s, 1) if a == 'interact': return interact(s) if a == 'wait': return wind(s) if a == 'next' and s.page < PAGE_COUNT - 1 and s.pages[s.page].complete: return replace(s, page=s.page + 1, events=('次のページへ。前の絵は残っています',)) if a == 'prev' and s.page > 0: return replace(s, page=s.page - 1, events=('前のページへ。状態も完成絵も保持',)) if a == 'restart': p = list(s.pages) p[s.page] = fresh(s.page) m = list(s.memories) m[s.page] = '' return replace(s, pages=tuple(p), memories=tuple(m), events=('このページだけ、はじめから',)) return s class App: def __init__(self, save_path=None): self.s = new() self.history = [] self.mode = 'words' if STAGE == 1 else 'walk' self.cursor = 0 self.pair = [] self.previous = set() self.gate = False self.notice = 'ready' self.save_path = save_path self.last_save = None self.dialog = '' def apply(self, action): if action == 'undo': if STAGE < 5 or not self.history: self.notice = 'no_undo' return self.notice self.s = self.history.pop() self.pair = [] self.notice = 'undo' return self.notice old = self.s self.s = step(old, action) if self.s == old: self.notice = 'blocked:' + action return self.notice if STAGE >= 5: self.history.append(old) self.history = self.history[-1000:] if action == 'swap': self.pair = [] if action in ('next', 'prev', 'restart'): self.mode = 'words' if STAGE == 1 else 'walk' self.pair = [] self.notice = action return action def feed(self, down): # 選択だけでは状態/時間を変えない。原作と同じ全解放ゲート。 down = set(down) & set(CONTROL_KEYS) fresh = down - self.previous self.previous = down if not down: self.gate = False if self.gate or not fresh: return None self.gate = True if len(fresh) > 1: self.notice = 'ignored:multiple' return self.notice key = next(iter(fresh)) if self.dialog: if key == 'X': self.dialog = '' self.notice = 'cancel' elif key == 'RETURN': self.dialog = '' self.apply('restart') return self.notice if key == 'R': self.dialog = 'restart' self.notice = 'confirm:restart' return self.notice if key == 'X': return self.apply('undo') if key in ('S', 'L') and STAGE >= 5: if not self.save_path: self.notice = 'use_--save-book' return self.notice try: if key == 'S': self.last_save = str(save_book(self.save_path, self.s, self.history)) self.notice = 'saved' else: state, history = load_book(self.save_path) self.s, self.history = state, list(history) self.mode, self.pair = 'walk', [] self.notice = 'loaded' except (OSError, ValueError, KeyError, TypeError, RecursionError): self.notice = 'save_failed' if key == 'S' else 'load_failed' return self.notice if key == 'N': return self.apply('next') if key == 'Q': return self.apply('prev') if key == 'TAB' and STAGE >= 2: self.mode = 'words' if self.mode == 'walk' else 'walk' self.pair = [] self.notice = 'mode' return self.notice if key == 'SPACE': return self.apply('wait') if self.mode == 'words': if key in ('LEFT','RIGHT','UP','DOWN'): self.cursor = 1 - self.cursor self.notice = 'select:object' elif key == 'Z': if self.cursor in self.pair: self.pair.remove(self.cursor) else: self.pair.append(self.cursor) self.notice = 'select:pair' elif key == 'RETURN': if len(self.pair) == 2: return self.apply('swap') self.notice = 'select_two_targets' else: if key in ('LEFT','RIGHT'): return self.apply(key.lower()) if key == 'Z': return self.apply('interact') return self.notice def draw(self, p): s = self.s i = s.page page = s.pages[i] p.cls(1) p.rect(0, 0, WIDTH, 47, 2) p.text(16, 10, 'LIVING STORYBOOK / STAGE ' + str(STAGE), 10) p.text(16, 28, TITLE, 7) p.text(540, 10, 'CHAPTER '+str(i+1)+' / '+str(PAGE_COUNT), 6) p.text(540, 28, 'TICK '+str(page.ticks)+' / HISTORY '+str(len(self.history)), 6) p.text(16, 59, ('WORDS: CHOOSE TWO TARGETS' if self.mode == 'words' else 'WALK: MOVE / INTERACT'), 10) p.text(16, 75, 'SELECTING DOES NOT ADVANCE PHYSICS OR TIME.', 6) for j in range(2): x = 16 + j * 390 p.rect(x, 98, 376, 76, 2) p.rectb(x, 98, 376, 76, 10 if self.cursor == j and self.mode == 'words' else 5) p.text(x+12, 110, ('[X] ' if j in self.pair else '[ ] ') + EN_NAMES[i][j], 11 if j in self.pair else 7) p.text(x+12, 130, 'WORD: '+EN_TAGS[page.tags[j]], 10) effect = ('FLOATS' if page.tags[j] == '軽い' else 'SINKS') if i == 0 else ('SIZE 3' if page.tags[j] == '長い' else 'SIZE 1') if i == 1 else ('WIND +1 CELL' if page.tags[j] == '軽い' else 'STAYS HERE') p.text(x+12, 150, 'EFFECT: '+effect, 6) p.text(16, 190, ('FERRY: REACH LAND CELL 5' if i == 0 else 'BRIDGE: REACH 6, THEN Z TO RAISE FLAG' if i == 1 else 'WIND: REACH 6 RIDING, THEN Z TO GET OFF'), 7) if STAGE == 1: p.text(16, 206, 'STAGE 1: OBSERVE ONLY. WALK / GOAL START IN STAGE 2.', 9) for cell in range(7): x = 64 + cell * 100 p.text(x+26, 230, str(cell), 6) p.rect(x, 304, 88, 37, 4 if cell in LAND[i] else 12 if i == 0 else 0) p.rectb(x, 304, 88, 37, 5) p.text(x+18, 326, 'LAND' if cell in LAND[i] else 'WATER' if i == 0 else 'GAP', 7) if i == 1: p.rect(258, 294, 288 if page.tags[0] == '長い' else 88, 9, 9) p.rect(718, 254, 3, 51, 6) flag_y = 253 if page.raised else 285 p.rect(721, flag_y, 42 if page.tags[1] == '長い' else 14, 12, 8) else: for j, cell in enumerate(page.objects): x = 80 + cell * 100 y = (281 if page.tags[j] == '軽い' else 349) if i == 0 else (263 if page.tags[j] == '軽い' else 286) if j == 0: p.rect(x, y, 57, 13, 9) p.line(x, y+13, x+57, y+13, 4) else: p.rect(x+13, y-4, 27, 18, 13 if i == 0 else 4) p.text(x+16, y+22, 'A' if j == 0 else 'B', 7) hx = 108 + page.hero * 100 hy = 243 if i == 2 and page.riding >= 0 and page.tags[page.riding] == '軽い' else 272 p.circ(hx, hy, 5, 10) p.line(hx, hy+5, hx, hy+19, 7) p.line(hx-6, hy+12, hx+6, hy+12, 7) p.text(16, 387, 'HERO '+str(page.hero)+' / RIDING '+('-' if page.riding < 0 else EN_NAMES[i][page.riding])+' / LAST LAND '+str(page.last_land), 7) p.text(16, 404, 'OBSERVATION: WORDS -> FLOAT / SINK. NO GOAL YET.' if STAGE == 1 else 'PAGE GOAL: '+('COMPLETE' if page.complete else 'OPEN')+' / WORDS CAN STILL CHANGE THE ENDING.', 11 if page.complete else 6) p.rect(14, 423, 772, 61, 0) p.text(22, 429, 'REMEMBERED CHAPTER OUTCOMES (IN THIS BOOK)', 6) for n, item in enumerate(s.pages): memory = EN_SCENES.get(s.memories[n], '-') p.text(22, 441+n*12, str(n+1)+': '+memory, 11 if s.memories[n] else 5) p.text(16, 494, 'STATUS: '+self.notice+(' / ENTER RESTARTS PAGE, X CANCELS' if self.dialog else ''), 10) p.text(16, 511, 'ARROWS TARGET / Z SELECT / ENTER SWAP | R RETRY / ENTER CONFIRM / X CANCEL' if STAGE == 1 else 'WORDS: ARROWS TARGET / Z SELECT / ENTER SWAP | TAB WORDS-WALK', 7) p.text(16, 527, 'NO WALK / WAIT / CHAPTERS / UNDO / SAVE YET | ESC QUIT' if STAGE == 1 else 'WALK: LEFT-RIGHT / Z PULL | SPACE WAIT | R RETRY | ESC QUIT' if STAGE == 2 else 'WALK: LEFT-RIGHT / Z USE | SPACE WAIT | N NEXT / Q PREV | R RETRY', 6) p.text(16, 543, 'X UNDO / S SAVE / L LOAD (--save-book) | ESC QUIT' if STAGE == 5 else 'MEMORY IS IN RAM. STAGE 5 ADDS UNDO AND SAVE.', 6) CONTROL_KEYS = ('LEFT','RIGHT','UP','DOWN','TAB','Z','RETURN','SPACE','N','Q','R','X','S','L') SWAP_KEYS = ('TAB','Z','RIGHT','Z','RETURN','TAB') DEMO_KEYS = SWAP_KEYS + ('RIGHT',)*4 + ('N',) + SWAP_KEYS + ('RIGHT',)*6 + ('Z','N','RIGHT') + ('SPACE',)*5 + ('Z',) def parse_args(argv=None): import argparse 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') if STAGE >= 5: parser.add_argument('--save-book', help='S/L use this explicit .json; valid tutorial book may be replaced') args = parser.parse_args(argv) if (args.screenshot or args.report) and not (args.headless_smoke or args.demo_checkpoint): parser.error('outputs require a headless mode') outputs = [] for name, suffix in (('screenshot','.png'),('report','.json'),('save_book','.json')): value = getattr(args, name, None) if value: path = Path(value).expanduser().absolute() if path.suffix.lower() != suffix or path.is_symlink(): parser.error(name+' needs a regular '+suffix+' path') outputs.append(path.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') return args def main(argv=None): from pathlib import Path args = parse_args(argv) # モデルのimportと試験にはPyxelが不要。CLIの検査後にだけ読み込む。 import pyxel as p headless = args.headless_smoke or args.demo_checkpoint app = App(getattr(args, 'save_book', None)) sequence = DEMO_KEYS if args.demo_checkpoint else () limit = max(12, 2*len(sequence)+4) keys = {name:getattr(p,'KEY_'+name) for name in CONTROL_KEYS} p.init(WIDTH, HEIGHT, title='098 Storybook - Stage '+str(STAGE), fps=30, display_scale=1, headless=headless) finished = False def update(): if headless: index = p.frame_count // 2 app.feed({sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set()) else: app.feed({name for name, key in keys.items() if p.btn(key)}) 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().absolute() target.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(target), scale=1) if args.report: target = Path(args.report).expanduser().absolute() target.parent.mkdir(parents=True, exist_ok=True) data = {'stage':STAGE,'kind':'actual Pyxel headless automated render','keys':sequence, 'state':asdict(app.s),'history_length':len(app.history),'notice':app.notice, '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()