# 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。説明は LESSON.md。Python 3.11+ / Pyxel 2.9.9。""" """Pure deterministic simultaneous photography toy. No renderer or filesystem.""" from dataclasses import dataclass, replace, asdict import hashlib, json VERSION = '087-v1.0.1' DIR = ((0, -1), (1, 0), (0, 1), (-1, 0)) @dataclass(frozen=True) class Garden: id: str title: str f: tuple h: tuple exit: tuple props: tuple walls: tuple grass: tuple GARDENS = (Garden('A', '二つの植込み', (0, 2), (6, 2), (6, 4), ((1, 0), (3, 4), (5, 0)), ((2, 1), (2, 2), (4, 2), (4, 3)), ((3, 0), (4, 0), (5, 1), (3, 3), (3, 4))), Garden('B', '丸い池のまわり', (3, 0), (3, 4), (0, 4), ((0, 0), (6, 0), (6, 4)), ((2, 2), (3, 2), (4, 2)), ((0, 1), (0, 2), (1, 2), (5, 2), (6, 2), (6, 3))), Garden('C', '舞台袖', (0, 0), (6, 4), (6, 0), ((1, 3), (3, 0), (5, 3)), ((2, 0), (2, 1), (4, 3), (4, 4)), ((0, 3), (1, 3), (1, 4), (4, 1), (5, 1), (6, 1)))) @dataclass(frozen=True) class Input: fdir: int = -1 hdir: int = -1 shoot: bool = False rotate: bool = False alter: bool = False @dataclass(frozen=True) class State: t: int = 0 f: tuple = (0, 2) h: tuple = (6, 2) facing: int = 1 cd: tuple = (0, 0) exposure: tuple = () due: int = -1 photo_wait: int = 0 changed: tuple = () ripples: tuple = () result: str = '' events: tuple = () photo: tuple = () trail: tuple = () shots: int = 0 def new(g): return State(f=g.f, h=g.h, trail=(g.h,)) def walk(g, p, d): if d not in range(4): return p q = (p[0] + DIR[d][0], p[1] + DIR[d][1]) return q if 0 <= q[0] < 7 and 0 <= q[1] < 5 and (q not in g.walls) else p def mask(g, p, d): cells = [p] for _ in range(3): q = walk(g, cells[-1], d) if q == cells[-1]: break cells.append(q) return tuple(cells) def step(g, s, a): if s.result: return s tick = s.t + 1 events = [] f = s.f h = s.h face = s.facing cd = [max(0, n - 1) for n in s.cd] changed = s.changed ripple = tuple(((i, n - 1) for i, n in s.ripples if n > 1)) exp = s.exposure due = s.due wait = max(0, s.photo_wait - 1) photo = s.photo shots = s.shots result = '' if s.due < 0 and (not (a.shoot or a.rotate)) and (a.fdir in range(4)): face = a.fdir if s.cd[0] == 0: f = walk(g, s.f, a.fdir) if f != s.f: cd[0] = 1 else: events.append('写真家: 通れない') if not a.alter and a.hdir in range(4) and (s.cd[1] == 0): h = walk(g, s.h, a.hdir) if h != s.h: cd[1] = 1 events.append('ことり: 足音') else: events.append('ことり: 通れない') if a.alter: if h in g.props: i = g.props.index(h) if i not in changed: changed = tuple(sorted(changed + (i,))) events.append(('花が旗に', '雲が月に', '池が紙吹雪に')[i]) ripple = tuple(((j, n) for j, n in ripple if j != i)) + ((i, 2),) else: events.append('同じ景色は一度だけ') else: events.append('仕掛けの上で F') if s.due < 0: if a.shoot and a.rotate: events.append('撮影と回転は一つずつ') elif a.rotate: face = (face + 1) % 4 elif a.shoot: if s.photo_wait == 0: exp = mask(g, f, face) due = tick + 1 events.append('白枠予告: 次の刻に撮影') else: events.append('写真を送っています') if s.due == tick: shots += 1 hit = h in s.exposure and h not in g.grass photo = (tick, s.exposure, h if hit else None, hit) events.append('写った!' if hit else '空の写真も一枚の景色') result = 'F' if hit else '' exp = () due = -1 wait = 3 if not result and len(changed) >= 2 and (h == g.exit): result = 'H' events.append('ふたつの景色を置いて、おかえり!') if not result and tick >= 72: result = 'F' events.append('閉園。写真家の守りきり') return State(tick, f, h, face, tuple(cd), exp, due, wait, changed, ripple, result, tuple(events), photo, s.trail + (h,), shots) def public(g, s, ripples=True, replay=False): return {'t': s.t, 'f': s.f, 'h': s.h if s.h not in g.grass or (replay and s.result) else None, 'hidden': s.h in g.grass, 'facing': s.facing, 'mask': s.exposure, 'photo_wait': s.photo_wait, 'changed': s.changed, 'ripples': s.ripples if ripples else (), 'events': s.events, 'result': s.result, 'photo': s.photo, 'trail': s.trail if replay and s.result else ()} def digest(s): return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest() # この境界から下が教材の表示・操作。modelと画面を別々にテストできる。 STAGE = 4 STAGE_TITLE = 'EXIT AND CLOSING' def direction(keys, down): values = [i for i, key in enumerate(keys) if key in down] return values[0] if len(values) == 1 else -1 class App: def __init__(self): self.note = '' self.previous = set() self.replay = False if STAGE == 5: self.ui = Controller() else: self.state = new(GARDENS[0]) @property def garden(self): return self.ui.garden if STAGE == 5 else GARDENS[0] def current(self): return self.ui.state if STAGE == 5 else self.state def control(self, down): if STAGE == 5: event = self.ui.feed(down) if event == 'screenshot': self.note = 'F12 EXPORT OMITTED; USE DOCUMENTED QA OPTIONS' return event fresh = set(down) - self.previous self.previous = set(down) if 'R' in fresh: self.state, self.replay = new(self.garden), False return 'reset' if STAGE >= 4 and 'TAB' in fresh and self.state.result: self.replay = not self.replay if 'SPACE' not in fresh: return None a = Input(direction(('UP','RIGHT','DOWN','LEFT'), down), direction(('W','D','S','A'), down), STAGE >= 2 and 'M' in down, STAGE >= 2 and 'N' in down, STAGE >= 3 and 'F' in down) self.state = step(self.garden, self.state, a) return 'tick' def projection(self): replay = self.ui.replay if STAGE == 5 else self.replay ripples = not self.ui.variant if STAGE == 5 else True return public(self.garden, self.current(), ripples, replay) def draw(self, p): s, g, v = self.current(), self.garden, self.projection() p.cls(1) p.text(12,12,'087 HIDE PHOTO / STAGE '+str(STAGE)+' / '+STAGE_TITLE,10) p.text(12,29,'TICK '+str(s.t)+' / '+('CLOSE AT 72' if STAGE >= 4 else 'NO CLOSING RULE YET'),7) p.text(360,29,'GARDEN '+g.id+' / '+('REALTIME 4HZ' if STAGE == 5 else 'SPACE = ONE TICK'),11) ox, oy, size = 12, 52, 44 for y in range(5): for x in range(7): pos = (x,y); px, py = ox+x*size, oy+y*size color = 5 if pos in g.walls else 3 if STAGE >= 3 and pos in g.grass else 4 p.rect(px,py,size-2,size-2,color) if pos in g.walls: for dy in (10,21,32):p.line(px+4,py+dy,px+37,py+dy,6) if STAGE >= 3 and pos in g.grass: for dx in (9,21,33):p.line(px+dx,py+32,px+dx-4,py+15,11) if STAGE >= 3 and pos in g.props: index = g.props.index(pos) p.circ(px+21,py+22,9,10 if index in v['changed'] else 14) p.text(px+4,py+4,str(index+1),7) if index in v['changed']:p.text(px+17,py+19,'+',1) if STAGE >= 4 and pos == g.exit: p.rectb(px+8,py+9,26,28,10);p.text(px+12,py+31,'EXIT',10) for pos in v['trail']: p.circ(ox+pos[0]*size+21,oy+pos[1]*size+21,2,14) for index, ticks in v['ripples']: x,y=g.props[index];p.circb(ox+x*size+21,oy+y*size+21,19,10) for x,y in v['mask']: p.rectb(ox+x*size+2,oy+y*size+2,size-6,size-6,7) x,y=v['f'];cx,cy=ox+x*size+21,oy+y*size+23 p.rect(cx-10,cy-8,20,16,12);p.circ(cx,cy,5,7) dx,dy=DIR[v['facing']];p.line(cx,cy,cx+dx*18,cy+dy*18,10) if v['h'] is not None: x,y=v['h'];cx,cy=ox+x*size+22,oy+y*size+23 p.circ(cx,cy,8,10);p.tri(cx+7,cy-2,cx+13,cy+2,cx+7,cy+4,9);p.pset(cx+3,cy-3,0) lines=[ 'PHOTOGRAPHER: ARROWS', 'BIRD: W A S D', 'CONTACT / SWAP ALLOWED', 'MOVE COOLDOWN '+str(s.cd), '', 'M: SHOOT / N: ROTATE' if STAGE >= 2 else 'ONLY MOVEMENT IN STAGE 1', 'NEXT-TICK EXPOSURE' if STAGE >= 2 else 'NO SHUTTER / NO GRASS', 'PHOTO WAIT '+str(v['photo_wait']), 'DUE '+str(getattr(s,'due',-1)), 'F: CHANGE SCENERY' if STAGE >= 3 else 'NO SCENERY ACTION YET', 'CHANGED '+str(len(v['changed']))+' / 2', 'BIRD HIDDEN IN GRASS' if v['hidden'] else 'BIRD VISIBLE', 'REPLAY: POST-RESULT ONLY' if STAGE >= 4 else 'TRAIL NOT DISPLAYED', 'RESULT '+(v['result'] or 'PLAYING'), ] for i,line in enumerate(lines):p.text(336,55+i*14,line,10 if i in (0,1,13) else 7) events={'写真家: 通れない':'PHOTOGRAPHER: BLOCKED','ことり: 足音':'BIRD: FOOTSTEP', 'ことり: 通れない':'BIRD: BLOCKED','花が旗に':'FLOWER BECOMES FLAG', '雲が月に':'CLOUD BECOMES MOON','池が紙吹雪に':'POND BECOMES CONFETTI', '同じ景色は一度だけ':'SCENERY CHANGES ONCE','仕掛けの上で F':'USE F ON A PROP', '撮影と回転は一つずつ':'M + N CONFLICT: BIRD STILL ACTS', '白枠予告: 次の刻に撮影':'WHITE FRAME: EXPOSURE NEXT TICK', '写真を送っています':'SHUTTER IS COOLING DOWN','写った!':'PHOTO HIT: PHOTOGRAPHER WINS', '空の写真も一枚の景色':'PHOTO MISS','ふたつの景色を置いて、おかえり!':'TWO CHANGES + EXIT: BIRD WINS', '閉園。写真家の守りきり':'72 TICKS: PHOTOGRAPHER WINS'} for i,event in enumerate(v['events'][-3:]):p.text(12,289+i*15,events.get(event,'EVENT'),10) if v['result']:p.text(336,265,'WINNER: '+('BIRD' if v['result']=='H' else 'PHOTOGRAPHER'),10) if v['trail']:p.text(12,335,'POST-RESULT REPLAY / PINK DOTS = TRAIL',14) if STAGE == 5: p.text(12,358,'P/H: PAUSE / R: RESTART REQUEST / ESC: MENU / Q: QUIT',7) p.text(12,374,'RESULT: TAB REPLAY / SPACE CHANGE WHO PLAYS EACH ROLE',7) p.text(12,390,'ROLE KEYS STAY FIXED / RELEASE ALL KEYS AFTER TRANSITIONS',6) u=self.ui if u.title or u.paused or u.restart: p.rect(48,90,505,164,1);p.rectb(48,90,505,164,10) if u.title: overlay=['START / ENTER (THEN RELEASE ALL KEYS)', '1 / 2 / 3: GARDEN '+g.id, 'B: RIPPLES '+('OFF' if u.variant else 'ON'), 'ARROWS M N: PHOTOGRAPHER / WASD F: BIRD', 'TWO CHANGES + EXIT OR PHOTO / 72 TICKS'] elif u.restart: overlay=['RESTART REQUEST: WORLD IS STOPPED', 'Z: BIRD AGREES / ENTER: PHOTOGRAPHER AGREES', 'READY '+','.join(sorted(u.ready)), 'R: CANCEL / RELEASE ALL KEYS TO CONTINUE'] else:overlay=['PAUSED: WORLD IS STOPPED','P / H: RESUME, THEN RELEASE ALL KEYS'] for i,line in enumerate(overlay):p.text(63,108+i*25,line,10 if i==0 else 7) if u.swapped:p.text(336,280,'PLAYERS CHANGE ROLES; SAME KEYS',11) else: p.text(12,358,'HOLD ROLE KEYS, TAP SPACE, THEN RELEASE SPACE FOR NEXT TICK',7) p.text(12,374,'R: RESET LOADED STATE / ESC OR Q: QUIT',7) p.text(12,390,'SAVE CODE -> QUIT -> RERUN. R DOES NOT RELOAD THE FILE.',6) p.text(12,414,self.note or 'SYNTHETIC QA IS NOT A HUMAN TWO-PLAYER PLAYTEST.',6) def checkpoint(app): if STAGE == 5: app.control({'RETURN'});app.control(set()) for i in range(8):app.control({'UP','W','M'} if i == 0 else {'UP','W'}) else: keys={'UP','W','SPACE'} if STAGE >= 2:keys.add('M') app.control(keys);app.control(set()) FIXTURES = ('conflict','photo_due','photo_escape','grass','grass_miss','ripples', 'exit_priority','bird_win','timeout','replay_hidden','restart','role_swap') def fixture(app, name): if STAGE != 5:raise ValueError('boundary fixtures require stage 5') u=app.ui;u.title=False;g=u.garden if name == 'conflict': for _ in range(8):app.control({'M','N','UP','DOWN','W'}) elif name in ('photo_due','photo_escape'): u.state=replace(new(g),f=(0,0),h=(1,0)) u.tick(Input(shoot=True)) if name=='photo_escape':u.tick(Input(hdir=2)) elif name in ('grass','grass_miss','replay_hidden'): u.state=replace(new(g),f=(2,0),h=(3,0),trail=((6,2),(6,1),(5,1),(4,1),(3,1),(3,0))) if name=='grass_miss':u.tick(Input(shoot=True));u.tick(Input()) if name=='replay_hidden':u.state=replace(u.state,t=71);u.tick(Input());app.control({'TAB'}) elif name=='ripples': u.state=replace(new(g),h=g.props[0]);u.tick(Input(alter=True,hdir=2)) elif name=='exit_priority': u.state=replace(new(g),f=(6,3),h=(6,3),facing=2,changed=(0,1));u.tick(Input(shoot=True));u.tick(Input(hdir=2)) elif name=='bird_win': u.state=replace(new(g),h=(6,3),changed=(0,1));u.tick(Input(hdir=2)) elif name=='timeout':u.state=replace(new(g),t=71);u.tick(Input()) elif name=='restart':app.control({'R'});app.control(set());app.control({'Z'}) elif name=='role_swap':u.state=replace(new(g),result='H');app.control({'SPACE'});app.control(set()) app.note='SYNTHETIC BOUNDARY FIXTURE: '+name.upper() def exercise(): reserve_photo = True g=GARDENS[0];s=replace(new(g),t=70,f=(6,3),h=(6,3),facing=2,changed=(0,1)) s=step(g,s,Input(shoot=reserve_photo));s=step(g,s,Input(hdir=2)) return {'tick':s.t,'result':s.result,'photo':bool(s.photo and s.photo[3])} def output_path(parser, raw, extension): if raw is None:return None requested=Path(raw).expanduser() if requested.is_symlink():parser.error('output is a symlink; refusing') path=requested.resolve() if path.suffix != extension:parser.error('output extension must be '+extension) if not path.parent.is_dir():parser.error('output parent must already exist') if path.exists() or path.is_symlink():parser.error('output exists; refusing overwrite') return path def main(args): # args の出力先は起動時 cwd 基準の絶対パス。Pyxel import/init より先に確定済み。 import pyxel as p p.init(600,432,title='087 Hide photo lesson '+str(STAGE),fps=30, display_scale=1,headless=args.headless_smoke,quit_key=p.KEY_NONE) app=App() if args.demo_checkpoint:checkpoint(app) if args.fixture:fixture(app,args.fixture) names=('UP','RIGHT','DOWN','LEFT','W','D','S','A','M','N','F','SPACE', 'TAB','R','P','H','Z','RETURN','1','2','3','B','F12','ESCAPE','Q') keys={key:getattr(p,'KEY_'+key) for key in names} def finish(): app.draw(p) if args.screenshot: # save の上書き仕様へ依存しない。別名へ保存し、link の排他作成で確定する。 import tempfile,os fd,temp=tempfile.mkstemp(suffix='.png',dir=args.screenshot.parent) os.close(fd) try: p.screen.save(temp,1) os.link(temp,args.screenshot) finally:Path(temp).unlink(missing_ok=True) if args.state_json: report={'stage':STAGE,'fixture':args.fixture,'demo':args.demo_checkpoint, 'label':'Actual Pyxel synthetic rendering; not human input', 'state':asdict(app.current()),'public':app.projection()} with args.state_json.open('x',encoding='utf-8') as handle: json.dump(report,handle,ensure_ascii=False,indent=2) p.quit() def update(): if args.headless_smoke: if p.frame_count>=args.frames:finish() return down={key for key,value in keys.items() if p.btn(value)} if 'Q' in down or STAGE != 5 and 'ESCAPE' in down:finish();return app.control(down) 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('--fixture',choices=FIXTURES) parser.add_argument('--frames',type=int,default=3) parser.add_argument('--screenshot') parser.add_argument('--state-json') parser.add_argument('--exercise',action='store_true') args=parser.parse_args() if args.exercise: if args.headless_smoke or args.demo_checkpoint or args.fixture or args.screenshot or args.state_json or args.frames != 3: parser.error('--exercise cannot be combined with rendering options') print(json.dumps(exercise(),ensure_ascii=True));raise SystemExit(0) if (args.demo_checkpoint or args.fixture or args.screenshot or args.state_json) and not args.headless_smoke: parser.error('QA options require --headless-smoke') if args.fixture and (STAGE != 5 or args.demo_checkpoint):parser.error('fixture requires stage 5 without demo') if not 1<=args.frames<=300:parser.error('frames must be 1..300') args.screenshot=output_path(parser,args.screenshot,'.png') args.state_json=output_path(parser,args.state_json,'.json') main(args)